Merge branch 'main' into pr-1539

This commit is contained in:
tanzhenxin 2026-01-29 19:18:23 +08:00
commit 1c5b74ebd9
210 changed files with 22365 additions and 2747 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.8.0",
"version": "0.8.2",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.0"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.8.2"
},
"dependencies": {
"@google/genai": "1.30.0",
@ -55,6 +55,7 @@
"ink-spinner": "^5.0.0",
"lowlight": "^3.3.0",
"open": "^10.1.2",
"prompts": "^2.4.2",
"qrcode-terminal": "^0.12.0",
"react": "^19.1.0",
"read-package-up": "^11.0.0",
@ -84,6 +85,7 @@
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/yargs": "^17.0.32",
"@types/prompts": "^2.4.9",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"jsdom": "^26.1.0",

View file

@ -290,7 +290,7 @@ class GeminiAgent {
}
private async ensureAuthenticated(config: Config): Promise<void> {
const selectedType = this.settings.merged.security?.auth?.selectedType;
const selectedType = config.getModelsConfig().getCurrentAuthType();
if (!selectedType) {
throw acp.RequestError.authRequired(
'Use Qwen Code CLI to authenticate first.',

View file

@ -366,6 +366,9 @@ export type Usage = z.infer<typeof usageSchema>;
export const sessionUpdateMetaSchema = z.object({
usage: usageSchema.optional().nullable(),
durationMs: z.number().optional().nullable(),
toolName: z.string().optional().nullable(),
parentToolCallId: z.string().optional().nullable(),
subagentType: z.string().optional().nullable(),
});
export type SessionUpdateMeta = z.infer<typeof sessionUpdateMetaSchema>;
@ -573,6 +576,7 @@ export const sessionUpdateSchema = z.union([
kind: toolKindSchema,
locations: z.array(toolCallLocationSchema).optional(),
rawInput: z.unknown().optional(),
_meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call'),
status: toolCallStatusSchema,
title: z.string(),
@ -584,6 +588,7 @@ export const sessionUpdateSchema = z.union([
locations: z.array(toolCallLocationSchema).optional().nullable(),
rawInput: z.unknown().optional(),
rawOutput: z.unknown().optional(),
_meta: sessionUpdateMetaSchema.optional().nullable(),
sessionUpdate: z.literal('tool_call_update'),
status: toolCallStatusSchema.optional().nullable(),
title: z.string().optional().nullable(),

View file

@ -228,6 +228,7 @@ describe('HistoryReplayer', () => {
status: 'in_progress',
title: 'read_file',
rawInput: { path: '/test.ts' },
_meta: { toolName: 'read_file' },
}),
);
});
@ -280,6 +281,7 @@ describe('HistoryReplayer', () => {
],
// resultDisplay is included as rawOutput
rawOutput: 'File contents here',
_meta: { toolName: 'read_file' },
});
});

View file

@ -5,6 +5,9 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { Session } from './Session.js';
import type { Config, GeminiChat } from '@qwen-code/qwen-code-core';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
@ -38,10 +41,27 @@ describe('Session', () => {
addHistory: vi.fn(),
} as unknown as GeminiChat;
const toolRegistry = { getTool: vi.fn() };
const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) };
mockConfig = {
setApprovalMode: vi.fn(),
setModel: setModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
getChatRecordingService: vi.fn().mockReturnValue({
recordUserMessage: vi.fn(),
recordUiTelemetryEvent: vi.fn(),
}),
getToolRegistry: vi.fn().mockReturnValue(toolRegistry),
getFileService: vi.fn().mockReturnValue(fileService),
getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false),
getTargetDir: vi.fn().mockReturnValue(process.cwd()),
getDebugMode: vi.fn().mockReturnValue(false),
} as unknown as Config;
mockClient = {
@ -171,4 +191,61 @@ describe('Session', () => {
consoleErrorSpy.mockRestore();
});
});
describe('prompt', () => {
it('passes resolved paths to read_many_files tool', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-acp-session-'),
);
const fileName = 'README.md';
const filePath = path.join(tempDir, fileName);
try {
await fs.writeFile(filePath, '# Test\n', 'utf8');
const readManyFilesTool = {
buildAndExecute: vi.fn().mockResolvedValue({
llmContent: 'file content',
returnDisplay: 'ok',
}),
};
const toolRegistry = {
getTool: vi.fn((name: string) =>
name === 'read_many_files' ? readManyFilesTool : undefined,
),
};
const fileService = {
shouldGitIgnoreFile: vi.fn().mockReturnValue(false),
};
mockConfig.getTargetDir = vi.fn().mockReturnValue(tempDir);
mockConfig.getToolRegistry = vi.fn().mockReturnValue(toolRegistry);
mockConfig.getFileService = vi.fn().mockReturnValue(fileService);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue((async function* () {})());
const promptRequest: acp.PromptRequest = {
sessionId: 'test-session-id',
prompt: [
{ type: 'text', text: 'Check this file' },
{
type: 'resource_link',
name: fileName,
uri: `file://${fileName}`,
},
],
};
await session.prompt(promptRequest);
expect(readManyFilesTool.buildAndExecute).toHaveBeenCalledWith(
{ paths: [fileName] },
expect.any(AbortSignal),
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});
});

View file

@ -474,8 +474,17 @@ export class Session implements SessionContext {
}
).eventEmitter;
// Extract subagent metadata from TaskTool call
const parentToolCallId = callId;
const subagentType = (args['subagent_type'] as string) ?? '';
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(this, this.client);
const subAgentTracker = new SubAgentTracker(
this,
this.client,
parentToolCallId,
subagentType,
);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
@ -647,7 +656,11 @@ export class Session implements SessionContext {
const error = e instanceof Error ? e : new Error(String(e));
// Use ToolCallEmitter for error handling
await this.toolCallEmitter.emitError(callId, error);
await this.toolCallEmitter.emitError(
callId,
fc.name ?? 'unknown_tool',
error,
);
// Record tool error for session management
const errorParts = [
@ -979,7 +992,7 @@ export class Session implements SessionContext {
if (pathSpecsToRead.length > 0) {
const readResult = await readManyFilesTool.buildAndExecute(
{
paths_with_line_ranges: pathSpecsToRead,
paths: pathSpecsToRead,
},
abortSignal,
);

View file

@ -14,6 +14,7 @@ import type {
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
@ -101,6 +102,18 @@ function createInfoConfirmation(
};
}
// Helper to create a mock SubAgentStreamTextEvent with required fields
function createStreamTextEvent(
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
): SubAgentStreamTextEvent {
return {
subagentId: 'test-subagent',
round: 1,
timestamp: Date.now(),
...overrides,
};
}
describe('SubAgentTracker', () => {
let mockContext: SessionContext;
let mockClient: acp.Client;
@ -132,7 +145,12 @@ describe('SubAgentTracker', () => {
requestPermission: requestPermissionSpy,
} as unknown as acp.Client;
tracker = new SubAgentTracker(mockContext, mockClient);
tracker = new SubAgentTracker(
mockContext,
mockClient,
'parent-call-123',
'test-subagent',
);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
abortController = new AbortController();
});
@ -162,6 +180,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
it('should remove event listeners on cleanup', () => {
@ -182,6 +204,10 @@ describe('SubAgentTracker', () => {
SubAgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
});
@ -214,6 +240,11 @@ describe('SubAgentTracker', () => {
locations: [],
kind: 'other',
rawInput: { path: '/test.ts' },
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -283,6 +314,11 @@ describe('SubAgentTracker', () => {
sessionUpdate: 'tool_call_update',
toolCallId: 'call-123',
status: 'completed',
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -305,6 +341,11 @@ describe('SubAgentTracker', () => {
expect.objectContaining({
sessionUpdate: 'tool_call_update',
status: 'failed',
_meta: expect.objectContaining({
toolName: 'read_file',
parentToolCallId: 'parent-call-123',
subagentType: 'test-subagent',
}),
}),
);
});
@ -522,4 +563,163 @@ describe('SubAgentTracker', () => {
);
});
});
describe('stream text handling', () => {
it('should emit agent_message_chunk on STREAM_TEXT event', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Hello, this is a response from the model.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Hello, this is a response from the model.',
},
}),
);
});
it('should emit multiple chunks for multiple STREAM_TEXT events', async () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'First chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Second chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Third chunk' }),
);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledTimes(3);
});
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'First chunk ' },
}),
);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Second chunk ' },
}),
);
expect(sendUpdateSpy).toHaveBeenNthCalledWith(
3,
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: 'Third chunk' },
}),
);
});
it('should not emit when aborted', async () => {
tracker.setup(eventEmitter, abortController.signal);
abortController.abort();
const event = createStreamTextEvent({
text: 'This should not be emitted',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(sendUpdateSpy).not.toHaveBeenCalled();
});
it('should emit agent_thought_chunk when thought flag is true', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Let me think about this...',
thought: true,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: {
type: 'text',
text: 'Let me think about this...',
},
}),
);
});
it('should emit agent_message_chunk when thought flag is false', async () => {
tracker.setup(eventEmitter, abortController.signal);
const event = createStreamTextEvent({
text: 'Here is the answer.',
thought: false,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Here is the answer.',
},
}),
);
});
it('should emit agent_message_chunk when thought flag is undefined', async () => {
tracker.setup(eventEmitter, abortController.signal);
// Event without thought flag (undefined)
const event = createStreamTextEvent({
text: 'Default behavior text.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
});
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
sessionUpdate: 'agent_message_chunk',
content: {
type: 'text',
text: 'Default behavior text.',
},
}),
);
});
});
});

View file

@ -10,6 +10,7 @@ import type {
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
SubAgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
@ -77,11 +78,23 @@ export class SubAgentTracker {
constructor(
private readonly ctx: SessionContext,
private readonly client: acp.Client,
private readonly parentToolCallId: string,
private readonly subagentType: string,
) {
this.toolCallEmitter = new ToolCallEmitter(ctx);
this.messageEmitter = new MessageEmitter(ctx);
}
/**
* Gets the subagent metadata to attach to all events.
*/
private getSubagentMeta() {
return {
parentToolCallId: this.parentToolCallId,
subagentType: this.subagentType,
};
}
/**
* Sets up event listeners for a sub-agent's tool events.
*
@ -97,11 +110,13 @@ export class SubAgentTracker {
const onToolResult = this.createToolResultHandler(abortSignal);
const onApproval = this.createApprovalHandler(abortSignal);
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
const onStreamText = this.createStreamTextHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
@ -109,6 +124,7 @@ export class SubAgentTracker {
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@ -151,6 +167,7 @@ export class SubAgentTracker {
toolName: event.name,
callId: event.callId,
args: event.args,
subagentMeta: this.getSubagentMeta(),
});
};
}
@ -175,6 +192,7 @@ export class SubAgentTracker {
message: event.responseParts ?? [],
resultDisplay: event.resultDisplay,
args: state?.args,
subagentMeta: this.getSubagentMeta(),
});
// Clean up state
@ -269,7 +287,32 @@ export class SubAgentTracker {
const event = args[0] as SubAgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(event.usage, '', event.durationMs);
this.messageEmitter.emitUsageMetadata(
event.usage,
'',
event.durationMs,
this.getSubagentMeta(),
);
};
}
/**
* Creates a handler for stream text events.
* Emits agent message or thought chunks for text content from subagent model responses.
*/
private createStreamTextHandler(
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentStreamTextEvent;
if (abortSignal.aborted) return;
// Emit streamed text as agent message or thought based on the flag
void this.messageEmitter.emitMessage(
event.text,
'assistant',
event.thought ?? false,
);
};
}

View file

@ -53,6 +53,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
subagentMeta?: import('../types.js').SubagentMeta,
): Promise<void> {
const usage: Usage = {
promptTokens: usageMetadata.promptTokenCount,
@ -63,7 +64,9 @@ export class MessageEmitter extends BaseEmitter {
};
const meta =
typeof durationMs === 'number' ? { usage, durationMs } : { usage };
typeof durationMs === 'number'
? { usage, durationMs, ...subagentMeta }
: { usage, ...subagentMeta };
await this.sendUpdate({
sessionUpdate: 'agent_message_chunk',

View file

@ -77,6 +77,7 @@ describe('ToolCallEmitter', () => {
locations: [],
kind: 'other',
rawInput: { arg1: 'value1' },
_meta: { toolName: 'unknown_tool' },
});
});
@ -100,6 +101,7 @@ describe('ToolCallEmitter', () => {
locations: [{ path: '/test/file.ts', line: 10 }],
kind: 'edit',
rawInput: { path: '/test.ts' },
_meta: { toolName: 'edit_file' },
});
});
@ -123,6 +125,7 @@ describe('ToolCallEmitter', () => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
expect.objectContaining({
rawInput: {},
_meta: { toolName: 'test_tool' },
}),
);
});
@ -150,6 +153,7 @@ describe('ToolCallEmitter', () => {
locations: [], // Fallback to empty
kind: 'other', // Fallback to other
rawInput: { invalid: true },
_meta: { toolName: 'failing_tool' },
});
});
});
@ -170,6 +174,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-123',
status: 'completed',
rawOutput: 'Tool completed successfully',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -193,6 +198,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Something went wrong' },
},
],
_meta: { toolName: 'test_tool' },
});
});
@ -222,6 +228,7 @@ describe('ToolCallEmitter', () => {
newText: 'new content',
},
],
_meta: { toolName: 'edit_file' },
}),
);
});
@ -247,6 +254,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw output',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -264,6 +272,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-empty',
status: 'completed',
content: [],
_meta: { toolName: 'test_tool' },
});
});
@ -343,7 +352,7 @@ describe('ToolCallEmitter', () => {
it('should emit tool_call_update with failed status and error message', async () => {
const error = new Error('Connection timeout');
await emitter.emitError('call-123', error);
await emitter.emitError('call-123', 'test_tool', error);
expect(sendUpdateSpy).toHaveBeenCalledWith({
sessionUpdate: 'tool_call_update',
@ -355,6 +364,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Connection timeout' },
},
],
_meta: { toolName: 'test_tool' },
});
});
});
@ -498,6 +508,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: { unknownField: 'value', nested: { data: 123 } },
_meta: { toolName: 'test_tool' },
}),
);
});
@ -519,6 +530,7 @@ describe('ToolCallEmitter', () => {
toolCallId: 'call-extra',
status: 'completed',
rawOutput: 'Result text',
_meta: { toolName: 'test_tool' },
}),
);
});
@ -533,6 +545,7 @@ describe('ToolCallEmitter', () => {
const call = sendUpdateSpy.mock.calls[0][0];
expect(call.rawOutput).toBeUndefined();
expect(call._meta).toEqual({ toolName: 'test_tool' });
});
});
@ -623,6 +636,7 @@ describe('ToolCallEmitter', () => {
content: { type: 'text', text: 'Text content from message' },
},
],
_meta: { toolName: 'test_tool' },
});
});
@ -654,6 +668,7 @@ describe('ToolCallEmitter', () => {
},
],
rawOutput: 'raw result',
_meta: { toolName: 'test_tool' },
}),
);
});

View file

@ -11,6 +11,7 @@ import type {
ToolCallStartParams,
ToolCallResultParams,
ResolvedToolMetadata,
SubagentMeta,
} from '../types.js';
import type * as acp from '../../acp.js';
import type { Part } from '@google/genai';
@ -65,6 +66,10 @@ export class ToolCallEmitter extends BaseEmitter {
locations,
kind,
rawInput: params.args ?? {},
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
});
return true;
@ -120,6 +125,10 @@ export class ToolCallEmitter extends BaseEmitter {
toolCallId: params.callId,
status: params.success ? 'completed' : 'failed',
content: contentArray,
_meta: {
toolName: params.toolName,
...params.subagentMeta,
},
};
// Add rawOutput from resultDisplay
@ -135,9 +144,16 @@ export class ToolCallEmitter extends BaseEmitter {
* Use this for explicit error handling when not using emitResult.
*
* @param callId - The tool call ID
* @param toolName - The tool name
* @param error - The error that occurred
* @param subagentMeta - Optional subagent metadata
*/
async emitError(callId: string, error: Error): Promise<void> {
async emitError(
callId: string,
toolName: string,
error: Error,
subagentMeta?: SubagentMeta,
): Promise<void> {
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
@ -145,6 +161,10 @@ export class ToolCallEmitter extends BaseEmitter {
content: [
{ type: 'content', content: { type: 'text', text: error.message } },
],
_meta: {
toolName,
...subagentMeta,
},
});
}

View file

@ -25,6 +25,16 @@ export interface SessionContext extends SessionUpdateSender {
readonly config: Config;
}
/**
* Subagent metadata for tracking parent tool call context.
*/
export interface SubagentMeta {
/** ID of the parent TaskTool call that created this subagent */
parentToolCallId?: string;
/** Type of subagent (from TaskParams.subagent_type) */
subagentType?: string;
}
/**
* Parameters for emitting a tool call start event.
*/
@ -37,6 +47,8 @@ export interface ToolCallStartParams {
args?: Record<string, unknown>;
/** Status of the tool call */
status?: 'pending' | 'in_progress' | 'completed' | 'failed';
/** Optional subagent metadata */
subagentMeta?: SubagentMeta;
}
/**
@ -57,6 +69,8 @@ export interface ToolCallResultParams {
error?: Error;
/** Original args (fallback for TodoWriteTool todos extraction) */
args?: Record<string, unknown>;
/** Optional subagent metadata */
subagentMeta?: SubagentMeta;
}
/**

View file

@ -5,8 +5,16 @@
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { extensionConsentString, requestConsentOrFail } from './consent.js';
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
import {
extensionConsentString,
requestConsentOrFail,
requestChoicePluginNonInteractive,
} from './consent.js';
import type {
ExtensionConfig,
ClaudeMarketplaceConfig,
} from '@qwen-code/qwen-code-core';
import prompts from 'prompts';
vi.mock('../../i18n/index.js', () => ({
t: vi.fn((str: string, params?: Record<string, string>) => {
@ -20,6 +28,8 @@ vi.mock('../../i18n/index.js', () => ({
}),
}));
vi.mock('prompts');
describe('extensionConsentString', () => {
it('should include extension name', () => {
const config: ExtensionConfig = {
@ -241,3 +251,72 @@ describe('requestConsentOrFail', () => {
expect(mockRequestConsent).toHaveBeenCalled();
});
});
describe('requestChoicePluginNonInteractive', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should throw error when plugins array is empty', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [],
};
await expect(
requestChoicePluginNonInteractive(marketplace),
).rejects.toThrow('No plugins available in this marketplace.');
});
it('should return selected plugin name', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [
{
name: 'plugin1',
description: 'Plugin 1',
version: '1.0.0',
source: 'src1',
},
{
name: 'plugin2',
description: 'Plugin 2',
version: '1.0.0',
source: 'src2',
},
],
};
vi.mocked(prompts).mockResolvedValueOnce({ plugin: 'plugin2' });
const result = await requestChoicePluginNonInteractive(marketplace);
expect(result).toBe('plugin2');
expect(prompts).toHaveBeenCalledWith(
expect.objectContaining({
type: 'select',
name: 'plugin',
choices: expect.arrayContaining([
expect.objectContaining({ value: 'plugin1' }),
expect.objectContaining({ value: 'plugin2' }),
]),
}),
);
});
it('should throw error when selection is cancelled', async () => {
const marketplace: ClaudeMarketplaceConfig = {
name: 'test-marketplace',
owner: { name: 'Test Owner', email: 'test@example.com' },
plugins: [{ name: 'plugin1', version: '1.0.0', source: 'src1' }],
};
vi.mocked(prompts).mockResolvedValueOnce({ plugin: undefined });
await expect(
requestChoicePluginNonInteractive(marketplace),
).rejects.toThrow('Plugin selection cancelled.');
});
});

View file

@ -1,4 +1,5 @@
import type {
ClaudeMarketplaceConfig,
ExtensionConfig,
ExtensionRequestOptions,
SkillConfig,
@ -6,6 +7,7 @@ import type {
} from '@qwen-code/qwen-code-core';
import type { ConfirmationRequest } from '../../ui/types.js';
import chalk from 'chalk';
import prompts from 'prompts';
import { t } from '../../i18n/index.js';
/**
@ -27,6 +29,49 @@ export async function requestConsentNonInteractive(
return result;
}
/**
* Requests plugin selection from the user in non-interactive mode.
* Displays an interactive list with arrow key navigation.
*
* This should not be called from interactive mode as it will break the CLI.
*
* @param marketplace The marketplace config containing available plugins.
* @returns The name of the selected plugin.
*/
export async function requestChoicePluginNonInteractive(
marketplace: ClaudeMarketplaceConfig,
): Promise<string> {
const plugins = marketplace.plugins;
if (plugins.length === 0) {
throw new Error(t('No plugins available in this marketplace.'));
}
// Build choices for prompts select
const choices = plugins.map((plugin) => ({
title: chalk.green(chalk.bold(`[${plugin.name}]`)),
value: plugin.name,
}));
const response = await prompts({
type: 'select',
name: 'plugin',
message: t('Select a plugin to install from marketplace "{{name}}":', {
name: marketplace.name,
}),
choices,
initial: 0,
});
// Handle cancellation (Ctrl+C)
if (response.plugin === undefined) {
throw new Error(t('Plugin selection cancelled.'));
}
return response.plugin;
}
/**
* Requests consent from the user to perform an action, in interactive mode.
*

View file

@ -35,6 +35,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
vi.mock('./consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
requestConsentOrFail: mockRequestConsentOrFail,
requestChoicePluginNonInteractive: vi.fn(),
}));
vi.mock('../../config/trustedFolders.js', () => ({

View file

@ -16,6 +16,7 @@ import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
requestChoicePluginNonInteractive,
} from './consent.js';
import { t } from '../../i18n/index.js';
@ -54,6 +55,7 @@ export async function handleInstall(args: InstallArgs) {
loadSettings(workspaceDir).merged,
),
requestConsent,
requestChoicePlugin: requestChoicePluginNonInteractive,
});
await extensionManager.refreshCache();

View file

@ -32,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({
vi.mock('./consent.js', () => ({
requestConsentOrFail: vi.fn(),
requestConsentNonInteractive: vi.fn(),
requestChoicePluginNonInteractive: vi.fn(),
}));
describe('getExtensionManager', () => {

View file

@ -9,10 +9,12 @@ import { loadSettings } from '../../config/settings.js';
import {
requestConsentOrFail,
requestConsentNonInteractive,
requestChoicePluginNonInteractive,
} from './consent.js';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import * as os from 'node:os';
import chalk from 'chalk';
import { t } from '../../i18n/index.js';
export async function getExtensionManager(): Promise<ExtensionManager> {
const workspaceDir = process.cwd();
@ -22,6 +24,7 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
null,
requestConsentNonInteractive,
),
requestChoicePlugin: requestChoicePluginNonInteractive,
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
});
await extensionManager.refreshCache();
@ -46,32 +49,44 @@ export function extensionToOutputString(
const status = workspaceEnabled ? chalk.green('✓') : chalk.red('✗');
let output = `${inline ? '' : status} ${extension.config.name} (${extension.config.version})`;
output += `\n Path: ${extension.path}`;
output += `\n ${t('Path:')} ${extension.path}`;
if (extension.installMetadata) {
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
output += `\n ${t('Source:')} ${extension.installMetadata.source} (${t('Type:')} ${extension.installMetadata.type})`;
if (extension.installMetadata.ref) {
output += `\n Ref: ${extension.installMetadata.ref}`;
output += `\n ${t('Ref:')} ${extension.installMetadata.ref}`;
}
if (extension.installMetadata.releaseTag) {
output += `\n Release tag: ${extension.installMetadata.releaseTag}`;
output += `\n ${t('Release tag:')} ${extension.installMetadata.releaseTag}`;
}
}
output += `\n Enabled (User): ${userEnabled}`;
output += `\n Enabled (Workspace): ${workspaceEnabled}`;
output += `\n ${t('Enabled (User):')} ${userEnabled}`;
output += `\n ${t('Enabled (Workspace):')} ${workspaceEnabled}`;
if (extension.contextFiles.length > 0) {
output += `\n Context files:`;
output += `\n ${t('Context files:')}`;
extension.contextFiles.forEach((contextFile) => {
output += `\n ${contextFile}`;
});
}
if (extension.commands && extension.commands.length > 0) {
output += `\n Commands:`;
output += `\n ${t('Commands:')}`;
extension.commands.forEach((command) => {
output += `\n /${command}`;
});
}
if (extension.skills && extension.skills.length > 0) {
output += `\n ${t('Skills:')}`;
extension.skills.forEach((skill) => {
output += `\n ${skill.name}`;
});
}
if (extension.agents && extension.agents.length > 0) {
output += `\n ${t('Agents:')}`;
extension.agents.forEach((agent) => {
output += `\n ${agent.name}`;
});
}
if (extension.config.mcpServers) {
output += `\n MCP servers:`;
output += `\n ${t('MCP servers:')}`;
Object.keys(extension.config.mcpServers).forEach((key) => {
output += `\n ${key}`;
});

View file

@ -168,7 +168,7 @@ describe('validateAuthMethod', () => {
expect(validateAuthMethod(AuthType.USE_VERTEX_AI)).toBeNull();
});
it('should use config.modelsConfig.getModel() when Config is provided', () => {
it('should use config.getModelsConfig().getModel() when Config is provided', () => {
// Settings has a different model
vi.mocked(settings.loadSettings).mockReturnValue({
merged: {
@ -184,18 +184,18 @@ describe('validateAuthMethod', () => {
// Mock Config object that returns a different model (e.g., from CLI args)
const mockConfig = {
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('cli-model'),
},
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Set the env key for the CLI model, not the settings model
process.env['CLI_API_KEY'] = 'cli-key';
// Should use 'cli-model' from config.modelsConfig.getModel(), not 'settings-model'
// Should use 'cli-model' from config.getModelsConfig().getModel(), not 'settings-model'
const result = validateAuthMethod(AuthType.USE_OPENAI, mockConfig);
expect(result).toBeNull();
expect(mockConfig.modelsConfig.getModel).toHaveBeenCalled();
expect(mockConfig.getModelsConfig).toHaveBeenCalled();
});
it('should fail validation when Config provides different model without matching env key', () => {
@ -217,9 +217,9 @@ describe('validateAuthMethod', () => {
} as unknown as ReturnType<typeof settings.loadSettings>);
const mockConfig = {
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('cli-model'),
},
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
// Don't set CLI_API_KEY - validation should fail

View file

@ -60,9 +60,9 @@ function hasApiKeyForAuth(
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID resolution
// Use config.getModelsConfig().getModel() if available for accurate model ID resolution
// that accounts for CLI args, env vars, and settings. Fall back to settings.model.name.
const modelId = config?.modelsConfig.getModel() ?? settings.model?.name;
const modelId = config?.getModelsConfig().getModel() ?? settings.model?.name;
// Try to find model-specific envKey from modelProviders
const modelConfig = findModelConfig(modelProviders, authType, modelId);
@ -184,9 +184,9 @@ export function validateAuthMethod(
const modelProviders = settings.merged.modelProviders as
| ModelProvidersConfig
| undefined;
// Use config.modelsConfig.getModel() if available for accurate model ID
// Use config.getModelsConfig().getModel() if available for accurate model ID
const modelId =
config?.modelsConfig.getModel() ?? settings.merged.model?.name;
config?.getModelsConfig().getModel() ?? settings.merged.model?.name;
const modelConfig = findModelConfig(modelProviders, authMethod, modelId);
if (modelConfig && !modelConfig.baseUrl) {

View file

@ -13,18 +13,48 @@ import {
WriteFileTool,
DEFAULT_QWEN_MODEL,
OutputFormat,
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
import * as ServerConfig from '@qwen-code/qwen-code-core';
import { isWorkspaceTrusted } from './trustedFolders.js';
const createNativeLspServiceInstance = () => ({
discoverAndPrepare: vi.fn(),
start: vi.fn(),
definitions: vi.fn().mockResolvedValue([]),
references: vi.fn().mockResolvedValue([]),
workspaceSymbols: vi.fn().mockResolvedValue([]),
hover: vi.fn().mockResolvedValue(null),
documentSymbols: vi.fn().mockResolvedValue([]),
implementations: vi.fn().mockResolvedValue([]),
prepareCallHierarchy: vi.fn().mockResolvedValue([]),
incomingCalls: vi.fn().mockResolvedValue([]),
outgoingCalls: vi.fn().mockResolvedValue([]),
diagnostics: vi.fn().mockResolvedValue([]),
workspaceDiagnostics: vi.fn().mockResolvedValue([]),
codeActions: vi.fn().mockResolvedValue([]),
applyWorkspaceEdit: vi.fn().mockResolvedValue(false),
});
vi.mock('./trustedFolders.js', () => ({
isWorkspaceTrusted: vi
.fn()
.mockReturnValue({ isTrusted: true, source: 'file' }), // Default to trusted
}));
const nativeLspServiceMock = vi.mocked(NativeLspService);
const getLastLspInstance = () => {
const results = nativeLspServiceMock.mock.results;
if (results.length === 0) {
return undefined;
}
return results[results.length - 1]?.value as ReturnType<
typeof createNativeLspServiceInstance
>;
};
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('fs')>();
const pathMod = await import('node:path');
@ -79,6 +109,9 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actualServer = await importOriginal<typeof ServerConfig>();
return {
...actualServer,
NativeLspService: vi
.fn()
.mockImplementation(() => createNativeLspServiceInstance()),
IdeClient: {
getInstance: vi.fn().mockResolvedValue({
getConnectionStatus: vi.fn(),
@ -514,6 +547,10 @@ describe('loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
nativeLspServiceMock.mockReset();
nativeLspServiceMock.mockImplementation(
() => createNativeLspServiceInstance() as unknown as NativeLspService,
);
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
});
@ -543,6 +580,22 @@ describe('loadCliConfig', () => {
expect(config.getIncludePartialMessages()).toBe(true);
});
it('should initialize native LSP service when enabled', async () => {
process.argv = ['node', 'script.js', '--experimental-lsp'];
const argv = await parseArguments({} as Settings);
const settings: Settings = {};
const config = await loadCliConfig(settings, argv);
// LSP is enabled via --experimental-lsp flag
expect(config.isLspEnabled()).toBe(true);
expect(nativeLspServiceMock).toHaveBeenCalledTimes(1);
const lspInstance = getLastLspInstance();
expect(lspInstance).toBeDefined();
expect(lspInstance?.discoverAndPrepare).toHaveBeenCalledTimes(1);
expect(lspInstance?.start).toHaveBeenCalledTimes(1);
});
describe('Proxy configuration', () => {
const originalProxyEnv: { [key: string]: string | undefined } = {};
const proxyEnvVars = [

View file

@ -20,11 +20,15 @@ import {
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
type LspClient,
type ToolName,
EditTool,
ShellTool,
WriteFileTool,
NativeLspClient,
NativeLspService,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import type { Settings } from './settings.js';
@ -113,6 +117,7 @@ export interface CliArgs {
acp: boolean | undefined;
experimentalAcp: boolean | undefined;
experimentalSkills: boolean | undefined;
experimentalLsp: boolean | undefined;
extensions: string[] | undefined;
listExtensions: boolean | undefined;
openaiLogging: boolean | undefined;
@ -331,6 +336,12 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
return settings.experimental?.skills ?? legacySkills ?? false;
})(),
})
.option('experimental-lsp', {
type: 'boolean',
description:
'Enable experimental LSP (Language Server Protocol) feature for code intelligence',
default: false,
})
.option('channel', {
type: 'string',
choices: ['VSCode', 'ACP', 'SDK', 'CI'],
@ -713,6 +724,9 @@ export async function loadCliConfig(
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// LSP configuration: enabled only via --experimental-lsp flag
const lspEnabled = argv.experimentalLsp === true;
let lspClient: LspClient | undefined;
const question = argv.promptInteractive || argv.prompt || '';
const inputFormat: InputFormat =
(argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT;
@ -924,7 +938,7 @@ export async function loadCliConfig(
const modelProvidersConfig = settings.modelProviders;
return new Config({
const config = new Config({
sessionId,
sessionData,
embeddingModel: DEFAULT_QWEN_EMBEDDING_MODEL,
@ -932,7 +946,7 @@ export async function loadCliConfig(
targetDir: cwd,
includeDirectories,
loadMemoryFromIncludeDirectories:
settings.context?.loadMemoryFromIncludeDirectories || false,
settings.context?.loadFromIncludeDirectories || false,
importFormat: settings.context?.importFormat || 'tree',
debugMode,
question,
@ -1016,7 +1030,34 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
lsp: {
enabled: lspEnabled,
},
});
if (lspEnabled) {
try {
const lspService = new NativeLspService(
config,
config.getWorkspaceContext(),
appEvents,
fileService,
ideContextStore,
{
requireTrustedWorkspace: folderTrust,
},
);
await lspService.discoverAndPrepare();
await lspService.start();
lspClient = new NativeLspClient(lspService);
config.setLspClient(lspClient);
} catch (err) {
logger.warn('Failed to initialize native LSP service:', err);
}
}
return config;
}
function mergeExcludeTools(

View file

@ -218,14 +218,14 @@ describe('SettingsSchema', () => {
},
context: {
includeDirectories: ['/path/to/dir'],
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
},
};
// TypeScript should not complain about these properties
expect(settings.ui?.theme).toBe('dark');
expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']);
expect(settings.context?.loadMemoryFromIncludeDirectories).toBe(true);
expect(settings.context?.loadFromIncludeDirectories).toBe(true);
});
it('should have includeDirectories setting in schema', () => {
@ -243,21 +243,19 @@ describe('SettingsSchema', () => {
).toEqual([]);
});
it('should have loadMemoryFromIncludeDirectories setting in schema', () => {
it('should have loadFromIncludeDirectories setting in schema', () => {
expect(
getSettingsSchema().context?.properties
.loadMemoryFromIncludeDirectories,
getSettingsSchema().context?.properties.loadFromIncludeDirectories,
).toBeDefined();
expect(
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
.type,
getSettingsSchema().context?.properties.loadFromIncludeDirectories.type,
).toBe('boolean');
expect(
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
getSettingsSchema().context?.properties.loadFromIncludeDirectories
.category,
).toBe('Context');
expect(
getSettingsSchema().context?.properties.loadMemoryFromIncludeDirectories
getSettingsSchema().context?.properties.loadFromIncludeDirectories
.default,
).toBe(false);
});

View file

@ -18,6 +18,7 @@ import {
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
} from '@qwen-code/qwen-code-core';
import type { CustomTheme } from '../ui/themes/theme.js';
import { getLanguageSettingsOptions } from '../i18n/languages.js';
export type SettingsType =
| 'boolean'
@ -210,13 +211,7 @@ const SETTINGS_SCHEMA = {
'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' +
'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
{ value: 'ru', label: 'Русский (Russian)' },
{ value: 'de', label: 'Deutsch (German)' },
],
options: [] as readonly SettingEnumOption[],
},
outputLanguage: {
type: 'string',
@ -226,7 +221,7 @@ const SETTINGS_SCHEMA = {
default: 'auto',
description:
'The language for LLM output. Use "auto" to detect from system settings, ' +
'or set a specific language (e.g., "English", "中文", "日本語").',
'or set a specific language.',
showInDialog: true,
},
terminalBell: {
@ -693,7 +688,7 @@ const SETTINGS_SCHEMA = {
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
loadMemoryFromIncludeDirectories: {
loadFromIncludeDirectories: {
type: 'boolean',
label: 'Load Memory From Include Directories',
category: 'Context',
@ -1195,6 +1190,15 @@ const SETTINGS_SCHEMA = {
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;
export function getSettingsSchema(): SettingsSchemaType {
// Inject dynamic language options
const schema = SETTINGS_SCHEMA as unknown as SettingsSchema;
if (schema['general']?.properties?.['language']) {
(
schema['general'].properties['language'] as {
options?: SettingEnumOption[];
}
).options = getLanguageSettingsOptions();
}
return SETTINGS_SCHEMA;
}

View file

@ -14,8 +14,7 @@ import {
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js';
import { initializeLlmOutputLanguage } from '../utils/languageUtils.js';
import { initializeI18n, type SupportedLanguage } from '../i18n/index.js';
export interface InitializationResult {
authError: string | null;
@ -38,16 +37,13 @@ export async function initializeApp(
// Initialize i18n system
const languageSetting =
process.env['QWEN_CODE_LANG'] ||
settings.merged.general?.language ||
(settings.merged.general?.language as string) ||
'auto';
await initializeI18n(languageSetting);
// Auto-detect and set LLM output language on first use
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
await initializeI18n(languageSetting as SupportedLanguage | 'auto');
// Use authType from modelsConfig which respects CLI --auth-type argument
// over settings.security.auth.selectedType
const authType = config.modelsConfig.getCurrentAuthType();
const authType = config.getModelsConfig().getCurrentAuthType();
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
@ -61,7 +57,7 @@ export async function initializeApp(
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =
!config.modelsConfig.wasAuthTypeExplicitlyProvided() || !!authError;
!config.getModelsConfig().wasAuthTypeExplicitlyProvided() || !!authError;
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();

View file

@ -488,6 +488,7 @@ describe('gemini.tsx main function kitty protocol', () => {
excludeTools: undefined,
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
channel: undefined,
chatRecording: undefined,
});

View file

@ -53,6 +53,7 @@ import { getCliVersion } from './utils/version.js';
import { computeWindowTitle } from './utils/windowTitle.js';
import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js';
import { showResumeSessionPicker } from './ui/components/StandaloneSessionPicker.js';
import { initializeLlmOutputLanguage } from './utils/languageUtils.js';
export function validateDnsResolutionOrder(
order: string | undefined,
@ -252,7 +253,7 @@ export async function main() {
if (!settings.merged.security?.auth?.useExternal) {
// Validate authentication here because the sandbox will interfere with the Oauth2 web redirect.
try {
const authType = partialConfig.modelsConfig.getCurrentAuthType();
const authType = partialConfig.getModelsConfig().getCurrentAuthType();
// Fresh users may not have selected/persisted an authType yet.
// In that case, defer auth prompting/selection to the main interactive flow.
if (authType) {
@ -327,6 +328,10 @@ export async function main() {
// We are now past the logic handling potentially launching a child process
// to run Gemini CLI. It is now safe to perform expensive initialization that
// may have side effects.
// Initialize output language file before config loads to ensure it's included in context
initializeLlmOutputLanguage(settings.merged.general?.outputLanguage);
{
const config = await loadCliConfig(
settings.merged,

View file

@ -10,6 +10,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { homedir } from 'node:os';
import {
type SupportedLanguage,
SUPPORTED_LANGUAGES,
getLanguageNameFromLocale,
} from './languages.js';
@ -55,16 +56,17 @@ const getLocalePath = (
// Language detection
export function detectSystemLanguage(): SupportedLanguage {
const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG'];
if (envLang?.startsWith('zh')) return 'zh';
if (envLang?.startsWith('en')) return 'en';
if (envLang?.startsWith('ru')) return 'ru';
if (envLang?.startsWith('de')) return 'de';
if (envLang) {
for (const lang of SUPPORTED_LANGUAGES) {
if (envLang.startsWith(lang.code)) return lang.code;
}
}
try {
const locale = Intl.DateTimeFormat().resolvedOptions().locale;
if (locale.startsWith('zh')) return 'zh';
if (locale.startsWith('ru')) return 'ru';
if (locale.startsWith('de')) return 'de';
for (const lang of SUPPORTED_LANGUAGES) {
if (locale.startsWith(lang.code)) return lang.code;
}
} catch {
// Fallback to default
}

View file

@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
export type SupportedLanguage = 'en' | 'zh' | 'ru' | 'de' | string;
export type SupportedLanguage =
| 'en'
| 'zh'
| 'ru'
| 'de'
| 'ja'
| 'pt'
| string;
export interface LanguageDefinition {
/** The internal locale code used by the i18n system (e.g., 'en', 'zh'). */
@ -13,6 +20,8 @@ export interface LanguageDefinition {
id: string;
/** The full English name of the language (e.g., 'English', 'Chinese'). */
fullName: string;
/** The native name of the language (e.g., 'English', '中文'). */
nativeName?: string;
}
export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
@ -20,21 +29,37 @@ export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [
code: 'en',
id: 'en-US',
fullName: 'English',
nativeName: 'English',
},
{
code: 'zh',
id: 'zh-CN',
fullName: 'Chinese',
nativeName: '中文',
},
{
code: 'ru',
id: 'ru-RU',
fullName: 'Russian',
nativeName: 'Русский',
},
{
code: 'de',
id: 'de-DE',
fullName: 'German',
nativeName: 'Deutsch',
},
{
code: 'ja',
id: 'ja-JP',
fullName: 'Japanese',
nativeName: '日本語',
},
{
code: 'pt',
id: 'pt-BR',
fullName: 'Portuguese',
nativeName: 'Português',
},
];
@ -46,3 +71,28 @@ export function getLanguageNameFromLocale(locale: SupportedLanguage): string {
const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale);
return lang?.fullName || 'English';
}
/**
* Gets the language options for the settings schema.
*/
export function getLanguageSettingsOptions(): Array<{
value: string;
label: string;
}> {
return [
{ value: 'auto', label: 'Auto (detect from system)' },
...SUPPORTED_LANGUAGES.map((l) => ({
value: l.code,
label: l.nativeName
? `${l.nativeName} (${l.fullName})`
: `${l.fullName} (${l.id})`,
})),
];
}
/**
* Gets a string containing all supported language IDs (e.g., "en-US|zh-CN").
*/
export function getSupportedLanguageIds(separator = '|'): string {
return SUPPORTED_LANGUAGES.map((l) => l.id).join(separator);
}

View file

@ -298,7 +298,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Wie macht sich Qwen in dieser Sitzung? (optional)',
Bad: 'Schlecht',
Fine: 'In Ordnung',
Good: 'Gut',
Dismiss: 'Ignorieren',
'Not Sure Yet': 'Noch nicht sicher',
'Any other key': 'Beliebige andere Taste',
'Disable Loading Phrases': 'Ladesprüche deaktivieren',
@ -478,6 +480,17 @@ export default {
'Either an extension name or --all must be provided':
'Entweder ein Erweiterungsname oder --all muss angegeben werden',
'Lists installed extensions.': 'Listet installierte Erweiterungen auf.',
'Path:': 'Pfad:',
'Source:': 'Quelle:',
'Type:': 'Typ:',
'Ref:': 'Ref:',
'Release tag:': 'Release-Tag:',
'Enabled (User):': 'Aktiviert (Benutzer):',
'Enabled (Workspace):': 'Aktiviert (Arbeitsbereich):',
'Context files:': 'Kontextdateien:',
'Skills:': 'Skills:',
'Agents:': 'Agents:',
'MCP servers:': 'MCP-Server:',
'Link extension failed to install.':
'Verknüpfte Erweiterung konnte nicht installiert werden.',
'Extension "{{name}}" linked successfully and enabled.':
@ -507,6 +520,19 @@ export default {
'Manage extension settings.': 'Erweiterungseinstellungen verwalten.',
'You need to specify a command (set or list).':
'Sie müssen einen Befehl angeben (set oder list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'In diesem Marktplatz sind keine Plugins verfügbar.',
'Select a plugin to install from marketplace "{{name}}":':
'Wählen Sie ein Plugin zur Installation aus Marktplatz "{{name}}":',
'Plugin selection cancelled.': 'Plugin-Auswahl abgebrochen.',
'Select a plugin from "{{name}}"': 'Plugin aus "{{name}}" auswählen',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Verwenden Sie ↑↓ oder j/k zum Navigieren, Enter zum Auswählen, Escape zum Abbrechen',
'{{count}} more above': '{{count}} weitere oben',
'{{count}} more below': '{{count}} weitere unten',
'manage IDE integration': 'IDE-Integration verwalten',
'check status of IDE integration': 'Status der IDE-Integration prüfen',
'install required IDE companion for {{ideName}}':
@ -554,8 +580,8 @@ export default {
// ============================================================================
// Commands - Language
// ============================================================================
'Invalid language. Available: en-US, zh-CN':
'Ungültige Sprache. Verfügbar: en-US, zh-CN',
'Invalid language. Available: {{options}}':
'Ungültige Sprache. Verfügbar: {{options}}',
'Language subcommands do not accept additional arguments.':
'Sprach-Unterbefehle akzeptieren keine zusätzlichen Argumente.',
'Current UI language: {{lang}}': 'Aktuelle UI-Sprache: {{lang}}',
@ -564,12 +590,14 @@ export default {
'LLM output language not set': 'LLM-Ausgabesprache nicht festgelegt',
'Set UI language': 'UI-Sprache festlegen',
'Set LLM output language': 'LLM-Ausgabesprache festlegen',
'Usage: /language ui [zh-CN|en-US]': 'Verwendung: /language ui [zh-CN|en-US]',
'Usage: /language ui [{{options}}]': 'Verwendung: /language ui [{{options}}]',
'Usage: /language output <language>':
'Verwendung: /language output <Sprache>',
'Example: /language output 中文': 'Beispiel: /language output Deutsch',
'Example: /language output English': 'Beispiel: /language output English',
'Example: /language output English': 'Beispiel: /language output Englisch',
'Example: /language output 日本語': 'Beispiel: /language output Japanisch',
'Example: /language output Português':
'Beispiel: /language output Portugiesisch',
'UI language changed to {{lang}}': 'UI-Sprache geändert zu {{lang}}',
'LLM output language set to {{lang}}':
'LLM-Ausgabesprache auf {{lang}} gesetzt',
@ -585,12 +613,7 @@ export default {
'To request additional UI language packs, please open an issue on GitHub.':
'Um zusätzliche UI-Sprachpakete anzufordern, öffnen Sie bitte ein Issue auf GitHub.',
'Available options:': 'Verfügbare Optionen:',
' - zh-CN: Simplified Chinese': ' - zh-CN: Vereinfachtes Chinesisch',
' - en-US: English': ' - en-US: Englisch',
'Set UI language to Simplified Chinese (zh-CN)':
'UI-Sprache auf Vereinfachtes Chinesisch (zh-CN) setzen',
'Set UI language to English (en-US)':
'UI-Sprache auf Englisch (en-US) setzen',
'Set UI language to {{name}}': 'UI-Sprache auf {{name}} setzen',
// ============================================================================
// Commands - Approval Mode

View file

@ -315,7 +315,9 @@ export default {
'How is Qwen doing this session? (optional)':
'How is Qwen doing this session? (optional)',
Bad: 'Bad',
Fine: 'Fine',
Good: 'Good',
Dismiss: 'Dismiss',
'Not Sure Yet': 'Not Sure Yet',
'Any other key': 'Any other key',
'Disable Loading Phrases': 'Disable Loading Phrases',
@ -490,6 +492,17 @@ export default {
'Either an extension name or --all must be provided':
'Either an extension name or --all must be provided',
'Lists installed extensions.': 'Lists installed extensions.',
'Path:': 'Path:',
'Source:': 'Source:',
'Type:': 'Type:',
'Ref:': 'Ref:',
'Release tag:': 'Release tag:',
'Enabled (User):': 'Enabled (User):',
'Enabled (Workspace):': 'Enabled (Workspace):',
'Context files:': 'Context files:',
'Skills:': 'Skills:',
'Agents:': 'Agents:',
'MCP servers:': 'MCP servers:',
'Link extension failed to install.': 'Link extension failed to install.',
'Extension "{{name}}" linked successfully and enabled.':
'Extension "{{name}}" linked successfully and enabled.',
@ -515,6 +528,19 @@ export default {
'Manage extension settings.': 'Manage extension settings.',
'You need to specify a command (set or list).':
'You need to specify a command (set or list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'No plugins available in this marketplace.',
'Select a plugin to install from marketplace "{{name}}":':
'Select a plugin to install from marketplace "{{name}}":',
'Plugin selection cancelled.': 'Plugin selection cancelled.',
'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel',
'{{count}} more above': '{{count}} more above',
'{{count}} more below': '{{count}} more below',
'manage IDE integration': 'manage IDE integration',
'check status of IDE integration': 'check status of IDE integration',
'install required IDE companion for {{ideName}}':
@ -561,8 +587,8 @@ export default {
// ============================================================================
// Commands - Language
// ============================================================================
'Invalid language. Available: en-US, zh-CN':
'Invalid language. Available: en-US, zh-CN',
'Invalid language. Available: {{options}}':
'Invalid language. Available: {{options}}',
'Language subcommands do not accept additional arguments.':
'Language subcommands do not accept additional arguments.',
'Current UI language: {{lang}}': 'Current UI language: {{lang}}',
@ -571,11 +597,12 @@ export default {
'LLM output language not set': 'LLM output language not set',
'Set UI language': 'Set UI language',
'Set LLM output language': 'Set LLM output language',
'Usage: /language ui [zh-CN|en-US]': 'Usage: /language ui [zh-CN|en-US]',
'Usage: /language ui [{{options}}]': 'Usage: /language ui [{{options}}]',
'Usage: /language output <language>': 'Usage: /language output <language>',
'Example: /language output 中文': 'Example: /language output 中文',
'Example: /language output English': 'Example: /language output English',
'Example: /language output 日本語': 'Example: /language output 日本語',
'Example: /language output Português': 'Example: /language output Português',
'UI language changed to {{lang}}': 'UI language changed to {{lang}}',
'LLM output language set to {{lang}}': 'LLM output language set to {{lang}}',
'LLM output language rule file generated at {{path}}':
@ -590,11 +617,7 @@ export default {
'To request additional UI language packs, please open an issue on GitHub.':
'To request additional UI language packs, please open an issue on GitHub.',
'Available options:': 'Available options:',
' - zh-CN: Simplified Chinese': ' - zh-CN: Simplified Chinese',
' - en-US: English': ' - en-US: English',
'Set UI language to Simplified Chinese (zh-CN)':
'Set UI language to Simplified Chinese (zh-CN)',
'Set UI language to English (en-US)': 'Set UI language to English (en-US)',
'Set UI language to {{name}}': 'Set UI language to {{name}}',
// ============================================================================
// Commands - Approval Mode

View file

@ -0,0 +1,886 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// Japanese translations for Qwen Code CLI
export default {
// ============================================================================
// Help / UI Components
// ============================================================================
'Basics:': '基本操作:',
'Add context': 'コンテキストを追加',
'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.':
'{{symbol}} を使用してコンテキスト用のファイルを指定します(例: {{example}}) また、特定のファイルやフォルダを対象にできます',
'@': '@',
'@src/myFile.ts': '@src/myFile.ts',
'Shell mode': 'シェルモード',
'YOLO mode': 'YOLOモード',
'plan mode': 'プランモード',
'auto-accept edits': '編集を自動承認',
'Accepting edits': '編集を承認中',
'(shift + tab to cycle)': '(Shift + Tab で切り替え)',
'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).':
'{{symbol}} でシェルコマンドを実行(例: {{example1}})、または自然言語で入力(例: {{example2}})',
'!': '!',
'!npm run start': '!npm run start',
'start server': 'サーバーを起動',
'Commands:': 'コマンド:',
'shell command': 'シェルコマンド',
'Model Context Protocol command (from external servers)':
'Model Context Protocol コマンド(外部サーバーから)',
'Keyboard Shortcuts:': 'キーボードショートカット:',
'Jump through words in the input': '入力欄の単語間を移動',
'Close dialogs, cancel requests, or quit application':
'ダイアログを閉じる、リクエストをキャンセル、またはアプリを終了',
'New line': '改行',
'New line (Alt+Enter works for certain linux distros)':
'改行(一部のLinuxディストリビューションではAlt+Enterが有効)',
'Clear the screen': '画面をクリア',
'Open input in external editor': '外部エディタで入力を開く',
'Send message': 'メッセージを送信',
'Initializing...': '初期化中...',
'Connecting to MCP servers... ({{connected}}/{{total}})':
'MCPサーバーに接続中... ({{connected}}/{{total}})',
'Type your message or @path/to/file':
'メッセージを入力、@パス/ファイルでファイルを添付(D&D対応)',
"Press 'i' for INSERT mode and 'Esc' for NORMAL mode.":
"'i' でINSERTモード、'Esc' でNORMALモード",
'Cancel operation / Clear input (double press)':
'操作をキャンセル / 入力をクリア(2回押し)',
'Cycle approval modes': '承認モードを切り替え',
'Cycle through your prompt history': 'プロンプト履歴を順に表示',
'For a full list of shortcuts, see {{docPath}}':
'ショートカットの完全なリストは {{docPath}} を参照',
'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md',
'for help on Qwen Code': 'Qwen Code のヘルプ',
'show version info': 'バージョン情報を表示',
'submit a bug report': 'バグレポートを送信',
'About Qwen Code': 'Qwen Code について',
// ============================================================================
// System Information Fields
// ============================================================================
'CLI Version': 'CLIバージョン',
'Git Commit': 'Gitコミット',
Model: 'モデル',
Sandbox: 'サンドボックス',
'OS Platform': 'OSプラットフォーム',
'OS Arch': 'OSアーキテクチャ',
'OS Release': 'OSリリース',
'Node.js Version': 'Node.js バージョン',
'NPM Version': 'NPM バージョン',
'Session ID': 'セッションID',
'Auth Method': '認証方式',
'Base URL': 'ベースURL',
'Memory Usage': 'メモリ使用量',
'IDE Client': 'IDEクライアント',
// ============================================================================
// Commands - General
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
'list available Qwen Code tools. Usage: /tools [desc]':
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
'No tools available': '利用可能なツールはありません',
'View or change the approval mode for tool usage':
'ツール使用の承認モードを表示または変更',
'View or change the language setting': '言語設定を表示または変更',
'change the theme': 'テーマを変更',
'Select Theme': 'テーマを選択',
Preview: 'プレビュー',
'(Use Enter to select, Tab to configure scope)':
'(Enter で選択、Tab でスコープを設定)',
'(Use Enter to apply scope, Tab to select theme)':
'(Enter でスコープを適用、Tab でテーマを選択)',
'Theme configuration unavailable due to NO_COLOR env variable.':
'NO_COLOR 環境変数のためテーマ設定は利用できません',
'Theme "{{themeName}}" not found.': 'テーマ "{{themeName}}" が見つかりません',
'Theme "{{themeName}}" not found in selected scope.':
'選択したスコープにテーマ "{{themeName}}" が見つかりません',
'Clear conversation history and free up context':
'会話履歴をクリアしてコンテキストを解放',
'Compresses the context by replacing it with a summary.':
'コンテキストを要約に置き換えて圧縮',
'open full Qwen Code documentation in your browser':
'ブラウザで Qwen Code のドキュメントを開く',
'Configuration not available.': '設定が利用できません',
'change the auth method': '認証方式を変更',
'Copy the last result or code snippet to clipboard':
'最後の結果またはコードスニペットをクリップボードにコピー',
// ============================================================================
// Commands - Agents
// ============================================================================
'Manage subagents for specialized task delegation.':
'専門タスクを委任するサブエージェントを管理',
'Manage existing subagents (view, edit, delete).':
'既存のサブエージェントを管理(表示、編集、削除)',
'Create a new subagent with guided setup.':
'ガイド付きセットアップで新しいサブエージェントを作成',
// ============================================================================
// Agents - Management Dialog
// ============================================================================
Agents: 'エージェント',
'Choose Action': 'アクションを選択',
'Edit {{name}}': '{{name}} を編集',
'Edit Tools: {{name}}': 'ツールを編集: {{name}}',
'Edit Color: {{name}}': '色を編集: {{name}}',
'Delete {{name}}': '{{name}} を削除',
'Unknown Step': '不明なステップ',
'Esc to close': 'Esc で閉じる',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter で選択、↑↓ で移動、Esc で閉じる',
'Esc to go back': 'Esc で戻る',
'Enter to confirm, Esc to cancel': 'Enter で確定、Esc でキャンセル',
'Enter to select, ↑↓ to navigate, Esc to go back':
'Enter で選択、↑↓ で移動、Esc で戻る',
'Invalid step: {{step}}': '無効なステップ: {{step}}',
'No subagents found.': 'サブエージェントが見つかりません',
"Use '/agents create' to create your first subagent.":
"'/agents create' で最初のサブエージェントを作成してください",
'(built-in)': '(組み込み)',
'(overridden by project level agent)':
'(プロジェクトレベルのエージェントで上書き)',
'Project Level ({{path}})': 'プロジェクトレベル ({{path}})',
'User Level ({{path}})': 'ユーザーレベル ({{path}})',
'Built-in Agents': '組み込みエージェント',
'Using: {{count}} agents': '使用中: {{count}} エージェント',
'View Agent': 'エージェントを表示',
'Edit Agent': 'エージェントを編集',
'Delete Agent': 'エージェントを削除',
Back: '戻る',
'No agent selected': 'エージェントが選択されていません',
'File Path: ': 'ファイルパス: ',
'Tools: ': 'ツール: ',
'Color: ': '色: ',
'Description:': '説明:',
'System Prompt:': 'システムプロンプト:',
'Open in editor': 'エディタで開く',
'Edit tools': 'ツールを編集',
'Edit color': '色を編集',
'❌ Error:': '❌ エラー:',
'Are you sure you want to delete agent "{{name}}"?':
'エージェント "{{name}}" を削除してもよろしいですか?',
'Project Level (.qwen/agents/)': 'プロジェクトレベル (.qwen/agents/)',
'User Level (~/.qwen/agents/)': 'ユーザーレベル (~/.qwen/agents/)',
'✅ Subagent Created Successfully!':
'✅ サブエージェントの作成に成功しました!',
'Subagent "{{name}}" has been saved to {{level}} level.':
'サブエージェント "{{name}}" を {{level}} に保存しました',
'Name: ': '名前: ',
'Location: ': '場所: ',
'❌ Error saving subagent:': '❌ サブエージェント保存エラー:',
'Warnings:': '警告:',
'Step {{n}}: Choose Location': 'ステップ {{n}}: 場所を選択',
'Step {{n}}: Choose Generation Method': 'ステップ {{n}}: 作成方法を選択',
'Generate with Qwen Code (Recommended)': 'Qwen Code で生成(推奨)',
'Manual Creation': '手動作成',
'Generating subagent configuration...': 'サブエージェント設定を生成中...',
'Failed to generate subagent: {{error}}':
'サブエージェントの生成に失敗: {{error}}',
'Step {{n}}: Describe Your Subagent':
'ステップ {{n}}: サブエージェントを説明',
'Step {{n}}: Enter Subagent Name': 'ステップ {{n}}: サブエージェント名を入力',
'Step {{n}}: Enter System Prompt': 'ステップ {{n}}: システムプロンプトを入力',
'Step {{n}}: Enter Description': 'ステップ {{n}}: 説明を入力',
'Step {{n}}: Select Tools': 'ステップ {{n}}: ツールを選択',
'All Tools (Default)': '全ツール(デフォルト)',
'All Tools': '全ツール',
'Read-only Tools': '読み取り専用ツール',
'Read & Edit Tools': '読み取り&編集ツール',
'Read & Edit & Execution Tools': '読み取り&編集&実行ツール',
'Selected tools:': '選択されたツール:',
'Step {{n}}: Choose Background Color': 'ステップ {{n}}: 背景色を選択',
'Step {{n}}: Confirm and Save': 'ステップ {{n}}: 確認して保存',
'Esc to cancel': 'Esc でキャンセル',
cancel: 'キャンセル',
'go back': '戻る',
'↑↓ to navigate, ': '↑↓ で移動、',
'Name cannot be empty.': '名前は空にできません',
'System prompt cannot be empty.': 'システムプロンプトは空にできません',
'Description cannot be empty.': '説明は空にできません',
'Failed to launch editor: {{error}}': 'エディタの起動に失敗: {{error}}',
'Failed to save and edit subagent: {{error}}':
'サブエージェントの保存と編集に失敗: {{error}}',
'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent':
'"{{name}}" は {{level}} に既に存在します - 既存のサブエージェントを上書きします',
'Name "{{name}}" exists at user level - project level will take precedence':
'"{{name}}" はユーザーレベルに存在します - プロジェクトレベルが優先されます',
'Name "{{name}}" exists at project level - existing subagent will take precedence':
'"{{name}}" はプロジェクトレベルに存在します - 既存のサブエージェントが優先されます',
'Description is over {{length}} characters':
'説明が {{length}} 文字を超えています',
'System prompt is over {{length}} characters':
'システムプロンプトが {{length}} 文字を超えています',
'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)':
'このサブエージェントの役割と使用タイミングを説明してください(詳細に記述するほど良い結果が得られます)',
'e.g., Expert code reviewer that reviews code based on best practices...':
'例: ベストプラクティスに基づいてコードをレビューするエキスパートレビュアー...',
'All tools selected, including MCP tools':
'MCPツールを含むすべてのツールを選択',
'Read-only tools:': '読み取り専用ツール:',
'Edit tools:': '編集ツール:',
'Execution tools:': '実行ツール:',
'Press Enter to save, e to save and edit, Esc to go back':
'Enter で保存、e で保存して編集、Esc で戻る',
'Press Enter to continue, {{navigation}}Esc to {{action}}':
'Enter で続行、{{navigation}}Esc で{{action}}',
'Enter a clear, unique name for this subagent.':
'このサブエージェントの明確で一意な名前を入力してください',
'e.g., Code Reviewer': '例: コードレビュアー',
"Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.":
'このサブエージェントの動作を定義するシステムプロンプトを記述してください (詳細に書くほど良い結果が得られます)',
'e.g., You are an expert code reviewer...':
'例: あなたはエキスパートコードレビュアーです...',
'Describe when and how this subagent should be used.':
'このサブエージェントをいつどのように使用するかを説明してください',
'e.g., Reviews code for best practices and potential bugs.':
'例: ベストプラクティスと潜在的なバグについてコードをレビューします。',
// Commands - General (continued)
'(Use Enter to select{{tabText}})': '(Enter で選択{{tabText}})',
', Tab to change focus': '、Tab でフォーカス変更',
'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.':
'変更を確認するには Qwen Code を再起動する必要があります。 r を押して終了し、変更を適用してください',
'The command "/{{command}}" is not supported in non-interactive mode.':
'コマンド "/{{command}}" は非対話モードではサポートされていません',
'View and edit Qwen Code settings': 'Qwen Code の設定を表示・編集',
Settings: '設定',
'Vim Mode': 'Vim モード',
'Disable Auto Update': '自動更新を無効化',
Language: '言語',
'Output Format': '出力形式',
'Hide Tips': 'ヒントを非表示',
'Hide Banner': 'バナーを非表示',
'Show Memory Usage': 'メモリ使用量を表示',
'Show Line Numbers': '行番号を表示',
Text: 'テキスト',
JSON: 'JSON',
Plan: 'プラン',
Default: 'デフォルト',
'Auto Edit': '自動編集',
YOLO: 'YOLO',
'toggle vim mode on/off': 'Vim モードのオン/オフを切り替え',
'exit the cli': 'CLIを終了',
Timeout: 'タイムアウト',
'Max Retries': '最大リトライ回数',
'Auto Accept': '自動承認',
'Folder Trust': 'フォルダの信頼',
'Enable Prompt Completion': 'プロンプト補完を有効化',
'Debug Keystroke Logging': 'キーストロークのデバッグログ',
'Hide Window Title': 'ウィンドウタイトルを非表示',
'Show Status in Title': 'タイトルにステータスを表示',
'Hide Context Summary': 'コンテキスト要約を非表示',
'Hide CWD': '作業ディレクトリを非表示',
'Hide Sandbox Status': 'サンドボックス状態を非表示',
'Hide Model Info': 'モデル情報を非表示',
'Hide Footer': 'フッターを非表示',
'Show Citations': '引用を表示',
'Custom Witty Phrases': 'カスタムウィットフレーズ',
'Enable Welcome Back': 'ウェルカムバック機能を有効化',
'Disable Loading Phrases': 'ローディングフレーズを無効化',
'Screen Reader Mode': 'スクリーンリーダーモード',
'IDE Mode': 'IDEモード',
'Max Session Turns': '最大セッションターン数',
'Skip Next Speaker Check': '次の発言者チェックをスキップ',
'Skip Loop Detection': 'ループ検出をスキップ',
'Skip Startup Context': '起動時コンテキストをスキップ',
'Enable OpenAI Logging': 'OpenAI ログを有効化',
'OpenAI Logging Directory': 'OpenAI ログディレクトリ',
'Disable Cache Control': 'キャッシュ制御を無効化',
'Memory Discovery Max Dirs': 'メモリ検出の最大ディレクトリ数',
'Load Memory From Include Directories':
'インクルードディレクトリからメモリを読み込み',
'Respect .gitignore': '.gitignore を優先',
'Respect .qwenignore': '.qwenignore を優先',
'Enable Recursive File Search': '再帰的ファイル検索を有効化',
'Disable Fuzzy Search': 'ファジー検索を無効化',
'Enable Interactive Shell': '対話型シェルを有効化',
'Show Color': '色を表示',
'Use Ripgrep': 'Ripgrep を使用',
'Use Builtin Ripgrep': '組み込み Ripgrep を使用',
'Enable Tool Output Truncation': 'ツール出力の切り詰めを有効化',
'Tool Output Truncation Threshold': 'ツール出力切り詰めのしきい値',
'Tool Output Truncation Lines': 'ツール出力の切り詰め行数',
'Vision Model Preview': 'ビジョンモデルプレビュー',
'Tool Schema Compliance': 'ツールスキーマ準拠',
'Auto (detect from system)': '自動(システムから検出)',
'check session stats. Usage: /stats [model|tools]':
'セッション統計を確認。使い方: /stats [model|tools]',
'Show model-specific usage statistics.': 'モデル別の使用統計を表示',
'Show tool-specific usage statistics.': 'ツール別の使用統計を表示',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証',
'Manage workspace directories': 'ワークスペースディレクトリを管理',
'Add directories to the workspace. Use comma to separate multiple paths':
'ワークスペースにディレクトリを追加。複数パスはカンマで区切ってください',
'Show all directories in the workspace':
'ワークスペース内のすべてのディレクトリを表示',
'set external editor preference': '外部エディタの設定',
'Manage extensions': '拡張機能を管理',
'List active extensions': '有効な拡張機能を一覧表示',
'Update extensions. Usage: update <extension-names>|--all':
'拡張機能を更新。使い方: update <拡張機能名>|--all',
'manage IDE integration': 'IDE連携を管理',
'check status of IDE integration': 'IDE連携の状態を確認',
'install required IDE companion for {{ideName}}':
'{{ideName}} 用の必要なIDEコンパニオンをインストール',
'enable IDE integration': 'IDE連携を有効化',
'disable IDE integration': 'IDE連携を無効化',
'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.':
'現在の環境ではIDE連携はサポートされていません。この機能を使用するには、VS Code または VS Code 派生エディタで Qwen Code を実行してください',
'Set up GitHub Actions': 'GitHub Actions を設定',
'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)':
'複数行入力用のターミナルキーバインドを設定(VS Code、Cursor、Windsurf、Trae)',
'Please restart your terminal for the changes to take effect.':
'変更を有効にするにはターミナルを再起動してください',
'Failed to configure terminal: {{error}}':
'ターミナルの設定に失敗: {{error}}',
'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.':
'Windows で {{terminalName}} の設定パスを特定できません: APPDATA 環境変数が設定されていません',
'{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.':
'{{terminalName}} の keybindings.json は存在しますが、有効なJSON配列ではありません。ファイルを手動で修正するか、削除して自動設定を許可してください',
'File: {{file}}': 'ファイル: {{file}}',
'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.':
'{{terminalName}} の keybindings.json の解析に失敗しました。ファイルに無効なJSONが含まれています。手動で修正するか、削除して自動設定を許可してください',
'Error: {{error}}': 'エラー: {{error}}',
'Shift+Enter binding already exists': 'Shift+Enter バインドは既に存在します',
'Ctrl+Enter binding already exists': 'Ctrl+Enter バインドは既に存在します',
'Existing keybindings detected. Will not modify to avoid conflicts.':
'既存のキーバインドが検出されました。競合を避けるため変更をしません',
'Please check and modify manually if needed: {{file}}':
'必要に応じて手動で確認・変更してください: {{file}}',
'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.':
'{{terminalName}} に Shift+Enter と Ctrl+Enter のキーバインドを追加しました',
'Modified: {{file}}': '変更済み: {{file}}',
'{{terminalName}} keybindings already configured.':
'{{terminalName}} のキーバインドは既に設定されています',
'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)に最適化されています',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae',
'Terminal "{{terminal}}" is not supported yet.':
'ターミナル "{{terminal}}" はまだサポートされていません',
// Commands - Language
'Invalid language. Available: {{options}}':
'無効な言語です。使用可能: {{options}}',
'Language subcommands do not accept additional arguments.':
'言語サブコマンドは追加の引数を受け付けません',
'Current UI language: {{lang}}': '現在のUI言語: {{lang}}',
'Current LLM output language: {{lang}}': '現在のLLM出力言語: {{lang}}',
'LLM output language not set': 'LLM出力言語が設定されていません',
'Set UI language': 'UI言語を設定',
'Set LLM output language': 'LLM出力言語を設定',
'Usage: /language ui [{{options}}]': '使い方: /language ui [{{options}}]',
'Usage: /language output <language>': '使い方: /language output <言語>',
'Example: /language output 中文': '例: /language output 中文',
'Example: /language output English': '例: /language output English',
'Example: /language output 日本語': '例: /language output 日本語',
'Example: /language output Português': '例: /language output Português',
'UI language changed to {{lang}}': 'UI言語を {{lang}} に変更しました',
'LLM output language rule file generated at {{path}}':
'LLM出力言語ルールファイルを {{path}} に生成しました',
'Please restart the application for the changes to take effect.':
'変更を有効にするにはアプリケーションを再起動してください',
'Failed to generate LLM output language rule file: {{error}}':
'LLM出力言語ルールファイルの生成に失敗: {{error}}',
'Invalid command. Available subcommands:':
'無効なコマンドです。使用可能なサブコマンド:',
'Available subcommands:': '使用可能なサブコマンド:',
'To request additional UI language packs, please open an issue on GitHub.':
'追加のUI言語パックをリクエストするには、GitHub で Issue を作成してください',
'Available options:': '使用可能なオプション:',
'Set UI language to {{name}}': 'UI言語を {{name}} に設定',
// Approval Mode
'Approval Mode': '承認モード',
'Current approval mode: {{mode}}': '現在の承認モード: {{mode}}',
'Available approval modes:': '利用可能な承認モード:',
'Approval mode changed to: {{mode}}': '承認モードを変更しました: {{mode}}',
'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})':
'承認モードを {{mode}} に変更しました({{scope}} 設定{{location}}に保存)',
'Usage: /approval-mode <mode> [--session|--user|--project]':
'使い方: /approval-mode <モード> [--session|--user|--project]',
'Scope subcommands do not accept additional arguments.':
'スコープサブコマンドは追加の引数を受け付けません',
'Plan mode - Analyze only, do not modify files or execute commands':
'プランモード - 分析のみ、ファイルの変更やコマンドの実行はしません',
'Default mode - Require approval for file edits or shell commands':
'デフォルトモード - ファイル編集やシェルコマンドには承認が必要',
'Auto-edit mode - Automatically approve file edits':
'自動編集モード - ファイル編集を自動承認',
'YOLO mode - Automatically approve all tools':
'YOLOモード - すべてのツールを自動承認',
'{{mode}} mode': '{{mode}}モード',
'Settings service is not available; unable to persist the approval mode.':
'設定サービスが利用できません。承認モードを保存できません',
'Failed to save approval mode: {{error}}':
'承認モードの保存に失敗: {{error}}',
'Failed to change approval mode: {{error}}':
'承認モードの変更に失敗: {{error}}',
'Apply to current session only (temporary)':
'現在のセッションのみに適用(一時的)',
'Persist for this project/workspace': 'このプロジェクト/ワークスペースに保存',
'Persist for this user on this machine': 'このマシンのこのユーザーに保存',
'Analyze only, do not modify files or execute commands':
'分析のみ、ファイルの変更やコマンドの実行はしません',
'Require approval for file edits or shell commands':
'ファイル編集やシェルコマンドには承認が必要',
'Automatically approve file edits': 'ファイル編集を自動承認',
'Automatically approve all tools': 'すべてのツールを自動承認',
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'ワークスペースの承認モードが存在し、優先されます。ユーザーレベルの変更は効果がありません',
'(Use Enter to select, Tab to change focus)':
'(Enter で選択、Tab でフォーカス変更)',
'Apply To': '適用先',
'User Settings': 'ユーザー設定',
'Workspace Settings': 'ワークスペース設定',
// Memory
'Commands for interacting with memory.': 'メモリ操作のコマンド',
'Show the current memory contents.': '現在のメモリ内容を表示',
'Show project-level memory contents.': 'プロジェクトレベルのメモリ内容を表示',
'Show global memory contents.': 'グローバルメモリ内容を表示',
'Add content to project-level memory.':
'プロジェクトレベルのメモリにコンテンツを追加',
'Add content to global memory.': 'グローバルメモリにコンテンツを追加',
'Refresh the memory from the source.': 'ソースからメモリを更新',
'Usage: /memory add --project <text to remember>':
'使い方: /memory add --project <記憶するテキスト>',
'Usage: /memory add --global <text to remember>':
'使い方: /memory add --global <記憶するテキスト>',
'Attempting to save to project memory: "{{text}}"':
'プロジェクトメモリへの保存を試行中: "{{text}}"',
'Attempting to save to global memory: "{{text}}"':
'グローバルメモリへの保存を試行中: "{{text}}"',
'Current memory content from {{count}} file(s):':
'{{count}} 個のファイルからの現在のメモリ内容:',
'Memory is currently empty.': 'メモリは現在空です',
'Project memory file not found or is currently empty.':
'プロジェクトメモリファイルが見つからないか、現在空です',
'Global memory file not found or is currently empty.':
'グローバルメモリファイルが見つからないか、現在空です',
'Global memory is currently empty.': 'グローバルメモリは現在空です',
'Global memory content:\n\n---\n{{content}}\n---':
'グローバルメモリ内容:\n\n---\n{{content}}\n---',
'Project memory content from {{path}}:\n\n---\n{{content}}\n---':
'{{path}} からのプロジェクトメモリ内容:\n\n---\n{{content}}\n---',
'Project memory is currently empty.': 'プロジェクトメモリは現在空です',
'Refreshing memory from source files...':
'ソースファイルからメモリを更新中...',
'Add content to the memory. Use --global for global memory or --project for project memory.':
'メモリにコンテンツを追加。グローバルメモリには --global、プロジェクトメモリには --project を使用',
'Usage: /memory add [--global|--project] <text to remember>':
'使い方: /memory add [--global|--project] <記憶するテキスト>',
'Attempting to save to memory {{scope}}: "{{fact}}"':
'メモリ {{scope}} への保存を試行中: "{{fact}}"',
// MCP
'Authenticate with an OAuth-enabled MCP server':
'OAuth対応のMCPサーバーで認証',
'List configured MCP servers and tools':
'設定済みのMCPサーバーとツールを一覧表示',
'No MCP servers configured.': 'MCPサーバーが設定されていません',
'Restarts MCP servers.': 'MCPサーバーを再起動します',
'Config not loaded.': '設定が読み込まれていません',
'Could not retrieve tool registry.': 'ツールレジストリを取得できませんでした',
'No MCP servers configured with OAuth authentication.':
'OAuth認証が設定されたMCPサーバーはありません',
'MCP servers with OAuth authentication:': 'OAuth認証のMCPサーバー:',
'Use /mcp auth <server-name> to authenticate.':
'認証するには /mcp auth <サーバー名> を使用',
"MCP server '{{name}}' not found.": "MCPサーバー '{{name}}' が見つかりません",
"Successfully authenticated and refreshed tools for '{{name}}'.":
"'{{name}}' の認証とツール更新に成功しました",
"Failed to authenticate with MCP server '{{name}}': {{error}}":
"MCPサーバー '{{name}}' での認証に失敗: {{error}}",
"Re-discovering tools from '{{name}}'...":
"'{{name}}' からツールを再検出中...",
'Configured MCP servers:': '設定済みMCPサーバー:',
Ready: '準備完了',
Disconnected: '切断',
'{{count}} tool': '{{count}} ツール',
'{{count}} tools': '{{count}} ツール',
'Restarting MCP servers...': 'MCPサーバーを再起動中...',
// Chat
'Manage conversation history.': '会話履歴を管理します',
'List saved conversation checkpoints':
'保存された会話チェックポイントを一覧表示',
'No saved conversation checkpoints found.':
'保存された会話チェックポイントが見つかりません',
'List of saved conversations:': '保存された会話の一覧:',
'Note: Newest last, oldest first':
'注: 最新のものが下にあり、過去のものが上にあります',
'Save the current conversation as a checkpoint. Usage: /chat save <tag>':
'現在の会話をチェックポイントとして保存。使い方: /chat save <タグ>',
'Missing tag. Usage: /chat save <tag>':
'タグが不足しています。使い方: /chat save <タグ>',
'Delete a conversation checkpoint. Usage: /chat delete <tag>':
'会話チェックポイントを削除。使い方: /chat delete <タグ>',
'Missing tag. Usage: /chat delete <tag>':
'タグが不足しています。使い方: /chat delete <タグ>',
"Conversation checkpoint '{{tag}}' has been deleted.":
"会話チェックポイント '{{tag}}' を削除しました",
"Error: No checkpoint found with tag '{{tag}}'.":
"エラー: タグ '{{tag}}' のチェックポイントが見つかりません",
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>':
'チェックポイントから会話を再開。使い方: /chat resume <タグ>',
'Missing tag. Usage: /chat resume <tag>':
'タグが不足しています。使い方: /chat resume <タグ>',
'No saved checkpoint found with tag: {{tag}}.':
'タグ {{tag}} のチェックポイントが見つかりません',
'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?':
'タグ {{tag}} のチェックポイントは既に存在します。上書きしますか?',
'No chat client available to save conversation.':
'会話を保存するためのチャットクライアントがありません',
'Conversation checkpoint saved with tag: {{tag}}.':
'タグ {{tag}} で会話チェックポイントを保存しました',
'No conversation found to save.': '保存する会話が見つかりません',
'No chat client available to share conversation.':
'会話を共有するためのチャットクライアントがありません',
'Invalid file format. Only .md and .json are supported.':
'無効なファイル形式です。.md と .json のみサポートされています',
'Error sharing conversation: {{error}}': '会話の共有中にエラー: {{error}}',
'Conversation shared to {{filePath}}': '会話を {{filePath}} に共有しました',
'No conversation found to share.': '共有する会話が見つかりません',
'Share the current conversation to a markdown or json file. Usage: /chat share <file>':
'現在の会話をmarkdownまたはjsonファイルに共有。使い方: /chat share <ファイル>',
// Summary
'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md':
'プロジェクトサマリーを生成し、.qwen/PROJECT_SUMMARY.md に保存',
'No chat client available to generate summary.':
'サマリーを生成するためのチャットクライアントがありません',
'Already generating summary, wait for previous request to complete':
'サマリー生成中です。前のリクエストの完了をお待ちください',
'No conversation found to summarize.': '要約する会話が見つかりません',
'Failed to generate project context summary: {{error}}':
'プロジェクトコンテキストサマリーの生成に失敗: {{error}}',
'Saved project summary to {{filePathForDisplay}}.':
'プロジェクトサマリーを {{filePathForDisplay}} に保存しました',
'Saving project summary...': 'プロジェクトサマリーを保存中...',
'Generating project summary...': 'プロジェクトサマリーを生成中...',
'Failed to generate summary - no text content received from LLM response':
'サマリーの生成に失敗 - LLMレスポンスからテキストコンテンツを受信できませんでした',
// Model
'Switch the model for this session': 'このセッションのモデルを切り替え',
'Content generator configuration not available.':
'コンテンツジェネレーター設定が利用できません',
'Authentication type not available.': '認証タイプが利用できません',
'No models available for the current authentication type ({{authType}}).':
'現在の認証タイプ({{authType}})で利用可能なモデルはありません',
// Clear
'Starting a new session, resetting chat, and clearing terminal.':
'新しいセッションを開始し、チャットをリセットし、ターミナルをクリアしています',
'Starting a new session and clearing.':
'新しいセッションを開始してクリアしています',
// Compress
'Already compressing, wait for previous request to complete':
'圧縮中です。前のリクエストの完了をお待ちください',
'Failed to compress chat history.': 'チャット履歴の圧縮に失敗しました',
'Failed to compress chat history: {{error}}':
'チャット履歴の圧縮に失敗: {{error}}',
'Compressing chat history': 'チャット履歴を圧縮中',
'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.':
'チャット履歴を {{originalTokens}} トークンから {{newTokens}} トークンに圧縮しました',
'Compression was not beneficial for this history size.':
'この履歴サイズには圧縮の効果がありませんでした',
'Chat history compression did not reduce size. This may indicate issues with the compression prompt.':
'チャット履歴の圧縮でサイズが減少しませんでした。圧縮プロンプトに問題がある可能性があります',
'Could not compress chat history due to a token counting error.':
'トークンカウントエラーのため、チャット履歴を圧縮できませんでした',
'Chat history is already compressed.': 'チャット履歴は既に圧縮されています',
// Directory
'Configuration is not available.': '設定が利用できません',
'Please provide at least one path to add.':
'追加するパスを少なくとも1つ指定してください',
'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.':
'制限的なサンドボックスプロファイルでは /directory add コマンドはサポートされていません。代わりにセッション開始時に --include-directories を使用してください',
"Error adding '{{path}}': {{error}}":
"'{{path}}' の追加中にエラー: {{error}}",
'Successfully added QWEN.md files from the following directories if there are:\n- {{directories}}':
'以下のディレクトリから QWEN.md ファイルを追加しました(存在する場合):\n- {{directories}}',
'Error refreshing memory: {{error}}': 'メモリの更新中にエラー: {{error}}',
'Successfully added directories:\n- {{directories}}':
'ディレクトリを正常に追加しました:\n- {{directories}}',
'Current workspace directories:\n{{directories}}':
'現在のワークスペースディレクトリ:\n{{directories}}',
// Docs
'Please open the following URL in your browser to view the documentation:\n{{url}}':
'ドキュメントを表示するには、ブラウザで以下のURLを開いてください:\n{{url}}',
'Opening documentation in your browser: {{url}}':
' ブラウザでドキュメントを開きました: {{url}}',
// Dialogs - Tool Confirmation
'Do you want to proceed?': '続行しますか?',
'Yes, allow once': 'はい(今回のみ許可)',
'Allow always': '常に許可する',
No: 'いいえ',
'No (esc)': 'いいえ (Esc)',
'Yes, allow always for this session': 'はい、このセッションで常に許可',
'Modify in progress:': '変更中:',
'Save and close external editor to continue':
'続行するには外部エディタを保存して閉じてください',
'Apply this change?': 'この変更を適用しますか?',
'Yes, allow always': 'はい、常に許可',
'Modify with external editor': '外部エディタで編集',
'No, suggest changes (esc)': 'いいえ、変更を提案 (Esc)',
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',
'URLs to fetch:': '取得するURL:',
'MCP Server: {{server}}': 'MCPサーバー: {{server}}',
'Tool: {{tool}}': 'ツール: {{tool}}',
'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?':
'サーバー "{{server}}" からの MCPツール "{{tool}}" の実行を許可しますか?',
'Yes, always allow tool "{{tool}}" from server "{{server}}"':
'はい、サーバー "{{server}}" からのツール "{{tool}}" を常に許可',
'Yes, always allow all tools from server "{{server}}"':
'はい、サーバー "{{server}}" からのすべてのツールを常に許可',
// Dialogs - Shell Confirmation
'Shell Command Execution': 'シェルコマンド実行',
'A custom command wants to run the following shell commands:':
'カスタムコマンドが以下のシェルコマンドを実行しようとしています:',
// Dialogs - Pro Quota
'Pro quota limit reached for {{model}}.':
'{{model}} のProクォータ上限に達しました',
'Change auth (executes the /auth command)':
'認証を変更(/auth コマンドを実行)',
'Continue with {{model}}': '{{model}} で続行',
// Dialogs - Welcome Back
'Current Plan:': '現在のプラン:',
'Progress: {{done}}/{{total}} tasks completed':
'進捗: {{done}}/{{total}} タスク完了',
', {{inProgress}} in progress': '、{{inProgress}} 進行中',
'Pending Tasks:': '保留中のタスク:',
'What would you like to do?': '何をしますか?',
'Choose how to proceed with your session:':
'セッションの続行方法を選択してください:',
'Start new chat session': '新しいチャットセッションを開始',
'Continue previous conversation': '前回の会話を続行',
'👋 Welcome back! (Last updated: {{timeAgo}})':
'👋 おかえりなさい!(最終更新: {{timeAgo}})',
'🎯 Overall Goal:': '🎯 全体目標:',
// Dialogs - Auth
'Get started': '始める',
'How would you like to authenticate for this project?':
'このプロジェクトの認証方法を選択してください:',
'OpenAI API key is required to use OpenAI authentication.':
'OpenAI認証を使用するには OpenAI APIキーが必要です',
'You must select an auth method to proceed. Press Ctrl+C again to exit.':
'続行するには認証方法を選択してください。Ctrl+C をもう一度押すと終了します',
'(Use Enter to Set Auth)': '(Enter で認証を設定)',
'Terms of Services and Privacy Notice for Qwen Code':
'Qwen Code の利用規約とプライバシー通知',
'Qwen OAuth': 'Qwen OAuth',
OpenAI: 'OpenAI',
'Failed to login. Message: {{message}}':
'ログインに失敗しました。メッセージ: {{message}}',
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.':
'認証は {{enforcedType}} に強制されていますが、現在 {{currentType}} を使用しています',
'Qwen OAuth authentication timed out. Please try again.':
'Qwen OAuth認証がタイムアウトしました。再度お試しください',
'Qwen OAuth authentication cancelled.':
'Qwen OAuth認証がキャンセルされました',
'Qwen OAuth Authentication': 'Qwen OAuth認証',
'Please visit this URL to authorize:':
'認証するには以下のURLにアクセスしてください:',
'Or scan the QR code below:': 'または以下のQRコードをスキャン:',
'Waiting for authorization': '認証を待っています',
'Time remaining:': '残り時間:',
'(Press ESC or CTRL+C to cancel)': '(ESC または CTRL+C でキャンセル)',
'Qwen OAuth Authentication Timeout': 'Qwen OAuth認証タイムアウト',
'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.':
'OAuthトークンが期限切れです({{seconds}}秒以上)。認証方法を再度選択してください',
'Press any key to return to authentication type selection.':
'認証タイプ選択に戻るには任意のキーを押してください',
'Waiting for Qwen OAuth authentication...': 'Qwen OAuth認証を待っています...',
'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.':
'注: Qwen OAuthを使用しても、settings.json内の既存のAPIキーはクリアされません。必要に応じて後でOpenAI認証に切り替えることができます',
'Authentication timed out. Please try again.':
'認証がタイムアウトしました。再度お試しください',
'Waiting for auth... (Press ESC or CTRL+C to cancel)':
'認証を待っています... (ESC または CTRL+C でキャンセル)',
'Failed to authenticate. Message: {{message}}':
'認証に失敗しました。メッセージ: {{message}}',
'Authenticated successfully with {{authType}} credentials.':
'{{authType}} 認証情報で正常に認証されました',
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}':
'無効な QWEN_DEFAULT_AUTH_TYPE 値: "{{value}}"。有効な値: {{validValues}}',
'OpenAI Configuration Required': 'OpenAI設定が必要です',
'Please enter your OpenAI configuration. You can get an API key from':
'OpenAI設定を入力してください。APIキーは以下から取得できます',
'API Key:': 'APIキー:',
'Invalid credentials: {{errorMessage}}': '無効な認証情報: {{errorMessage}}',
'Failed to validate credentials': '認証情報の検証に失敗しました',
'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel':
'Enter で続行、Tab/↑↓ で移動、Esc でキャンセル',
// Dialogs - Model
'Select Model': 'モデルを選択',
'(Press Esc to close)': '(Esc で閉じる)',
'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)':
'Alibaba Cloud ModelStudioの最新Qwen Coderモデル(バージョン: qwen3-coder-plus-2025-09-23)',
'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)':
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
// Dialogs - Permissions
'Manage folder trust settings': 'フォルダ信頼設定を管理',
// Status Bar
'Using:': '使用中:',
'{{count}} open file': '{{count}} 個のファイルを開いています',
'{{count}} open files': '{{count}} 個のファイルを開いています',
'(ctrl+g to view)': '(Ctrl+G で表示)',
'{{count}} {{name}} file': '{{count}} {{name}} ファイル',
'{{count}} {{name}} files': '{{count}} {{name}} ファイル',
'{{count}} MCP server': '{{count}} MCPサーバー',
'{{count}} MCP servers': '{{count}} MCPサーバー',
'{{count}} Blocked': '{{count}} ブロック',
'(ctrl+t to view)': '(Ctrl+T で表示)',
'(ctrl+t to toggle)': '(Ctrl+T で切り替え)',
'Press Ctrl+C again to exit.': 'Ctrl+C をもう一度押すと終了します',
'Press Ctrl+D again to exit.': 'Ctrl+D をもう一度押すと終了します',
'Press Esc again to clear.': 'Esc をもう一度押すとクリアします',
// MCP Status
'Please view MCP documentation in your browser:':
'ブラウザでMCPドキュメントを確認してください:',
'or use the cli /docs command': 'または CLI の /docs コマンドを使用',
'⏳ MCP servers are starting up ({{count}} initializing)...':
'⏳ MCPサーバーを起動中({{count}} 初期化中)...',
'Note: First startup may take longer. Tool availability will update automatically.':
'注: 初回起動には時間がかかる場合があります。ツールの利用可能状況は自動的に更新されます',
'Starting... (first startup may take longer)':
'起動中...(初回起動には時間がかかる場合があります)',
'{{count}} prompt': '{{count}} プロンプト',
'{{count}} prompts': '{{count}} プロンプト',
'(from {{extensionName}})': '({{extensionName}} から)',
OAuth: 'OAuth',
'OAuth expired': 'OAuth 期限切れ',
'OAuth not authenticated': 'OAuth 未認証',
'tools and prompts will appear when ready':
'ツールとプロンプトは準備完了後に表示されます',
'{{count}} tools cached': '{{count}} ツール(キャッシュ済み)',
'Tools:': 'ツール:',
'Parameters:': 'パラメータ:',
'Prompts:': 'プロンプト:',
Blocked: 'ブロック',
'💡 Tips:': '💡 ヒント:',
Use: '使用',
'to show server and tool descriptions': 'サーバーとツールの説明を表示',
'to show tool parameter schemas': 'ツールパラメータスキーマを表示',
'to hide descriptions': '説明を非表示',
'to authenticate with OAuth-enabled servers': 'OAuth対応サーバーで認証',
Press: '押す',
'to toggle tool descriptions on/off': 'ツール説明の表示/非表示を切り替え',
"Starting OAuth authentication for MCP server '{{name}}'...":
"MCPサーバー '{{name}}' のOAuth認証を開始中...",
// Startup Tips
'Tips for getting started:': '始めるためのヒント:',
'1. Ask questions, edit files, or run commands.':
'1. 質問したり、ファイルを編集したり、コマンドを実行したりできます',
'2. Be specific for the best results.':
'2. 具体的に指示すると最良の結果が得られます',
'files to customize your interactions with Qwen Code.':
'Qwen Code との対話をカスタマイズするためのファイル',
'for more information.': '詳細情報を確認できます',
// Exit Screen / Stats
'Agent powering down. Goodbye!': 'エージェントを終了します。さようなら!',
'To continue this session, run': 'このセッションを続行するには、次を実行:',
'Interaction Summary': 'インタラクション概要',
'Session ID:': 'セッションID:',
'Tool Calls:': 'ツール呼び出し:',
'Success Rate:': '成功率:',
'User Agreement:': 'ユーザー同意:',
reviewed: 'レビュー済み',
'Code Changes:': 'コード変更:',
Performance: 'パフォーマンス',
'Wall Time:': '経過時間:',
'Agent Active:': 'エージェント稼働時間:',
'API Time:': 'API時間:',
'Tool Time:': 'ツール時間:',
'Session Stats': 'セッション統計',
'Model Usage': 'モデル使用量',
Reqs: 'リクエスト',
'Input Tokens': '入力トークン',
'Output Tokens': '出力トークン',
'Savings Highlight:': '節約ハイライト:',
'of input tokens were served from the cache, reducing costs.':
'入力トークンがキャッシュから提供され、コストを削減しました',
'Tip: For a full token breakdown, run `/stats model`.':
'ヒント: トークンの詳細な内訳は `/stats model` を実行してください',
'Model Stats For Nerds': 'マニア向けモデル統計',
'Tool Stats For Nerds': 'マニア向けツール統計',
Metric: 'メトリック',
API: 'API',
Requests: 'リクエスト',
Errors: 'エラー',
'Avg Latency': '平均レイテンシ',
Tokens: 'トークン',
Total: '合計',
Prompt: 'プロンプト',
Cached: 'キャッシュ',
Thoughts: '思考',
Tool: 'ツール',
Output: '出力',
'No API calls have been made in this session.':
'このセッションではAPI呼び出しが行われていません',
'Tool Name': 'ツール名',
Calls: '呼び出し',
'Success Rate': '成功率',
'Avg Duration': '平均時間',
'User Decision Summary': 'ユーザー決定サマリー',
'Total Reviewed Suggestions:': '総レビュー提案数:',
' » Accepted:': ' » 承認:',
' » Rejected:': ' » 却下:',
' » Modified:': ' » 変更:',
' Overall Agreement Rate:': ' 全体承認率:',
'No tool calls have been made in this session.':
'このセッションではツール呼び出しが行われていません',
'Session start time is unavailable, cannot calculate stats.':
'セッション開始時刻が利用できないため、統計を計算できません',
// Loading
'Waiting for user confirmation...': 'ユーザーの確認を待っています...',
'(esc to cancel, {{time}})': '(Esc でキャンセル、{{time}})',
// Witty Loading Phrases
WITTY_LOADING_PHRASES: [
'運任せで検索中...',
'中の人がタイピング中...',
'ロジックを最適化中...',
'電子の数を確認中...',
'宇宙のバグをチェック中...',
'大量の0と1をコンパイル中...',
'HDDと思い出をデフラグ中...',
'ビットをこっそり入れ替え中...',
'ニューロンの接続を再構築中...',
'どこかに行ったセミコロンを捜索中...',
'フラックスキャパシタを調整中...',
'フォースと交感中...',
'アルゴリズムをチューニング中...',
'白いウサギを追跡中...',
'カセットフーフー中...',
'ローディングメッセージを考え中...',
'ほぼ完了...多分...',
'最新のミームについて調査中...',
'この表示を改善するアイデアを思索中...',
'この問題を考え中...',
'それはバグでなく誰も知らない新機能だよ',
'ダイヤルアップ接続音が終わるのを待機中...',
'コードに油を追加中...',
// かなり意訳が入ってるもの
'イヤホンをほどき中...',
'カフェインをコードに変換中...',
'天動説を地動説に書き換え中...',
'プールで時計の完成を待機中...',
'笑撃的な回答を用意中...',
'適切なミームを記述中...',
'Aボタンを押して次へ...',
'コードにリックロールを仕込み中...',
'プログラマーが貧乏なのはキャッシュを使いすぎるから...',
'プログラマーがダークモードなのはバグを見たくないから...',
'コードが壊れた?叩けば治るさ',
'USBの差し込みに挑戦中...',
],
};

File diff suppressed because it is too large Load diff

View file

@ -319,7 +319,9 @@ export default {
'How is Qwen doing this session? (optional)':
'Как дела у Qwen в этой сессии? (необязательно)',
Bad: 'Плохо',
Fine: 'Нормально',
Good: 'Хорошо',
Dismiss: 'Отклонить',
'Not Sure Yet': 'Пока не уверен',
'Any other key': 'Любая другая клавиша',
'Disable Loading Phrases': 'Отключить фразы при загрузке',
@ -494,6 +496,17 @@ export default {
'Either an extension name or --all must be provided':
'Необходимо указать имя расширения или --all',
'Lists installed extensions.': 'Показывает установленные расширения.',
'Path:': 'Путь:',
'Source:': 'Источник:',
'Type:': 'Тип:',
'Ref:': 'Ссылка:',
'Release tag:': 'Тег релиза:',
'Enabled (User):': 'Включено (Пользователь):',
'Enabled (Workspace):': 'Включено (Рабочее пространство):',
'Context files:': 'Контекстные файлы:',
'Skills:': 'Навыки:',
'Agents:': 'Агенты:',
'MCP servers:': 'MCP-серверы:',
'Link extension failed to install.':
'Не удалось установить связанное расширение.',
'Extension "{{name}}" linked successfully and enabled.':
@ -519,6 +532,19 @@ export default {
'Manage extension settings.': 'Управление настройками расширений.',
'You need to specify a command (set or list).':
'Необходимо указать команду (set или list).',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.':
'В этом маркетплейсе нет доступных плагинов.',
'Select a plugin to install from marketplace "{{name}}":':
'Выберите плагин для установки из маркетплейса "{{name}}":',
'Plugin selection cancelled.': 'Выбор плагина отменён.',
'Select a plugin from "{{name}}"': 'Выберите плагин из "{{name}}"',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'Используйте ↑↓ или j/k для навигации, Enter для выбора, Escape для отмены',
'{{count}} more above': 'ещё {{count}} выше',
'{{count}} more below': 'ещё {{count}} ниже',
'manage IDE integration': 'Управление интеграцией с IDE',
'check status of IDE integration': 'Проверить статус интеграции с IDE',
'install required IDE companion for {{ideName}}':
@ -565,8 +591,8 @@ export default {
// ============================================================================
// Команды - Язык
// ============================================================================
'Invalid language. Available: en-US, zh-CN':
'Неверный язык. Доступны: en-US, zh-CN, ru-RU',
'Invalid language. Available: {{options}}':
'Недопустимый язык. Доступны: {{options}}',
'Language subcommands do not accept additional arguments.':
'Подкоманды языка не принимают дополнительных аргументов.',
'Current UI language: {{lang}}': 'Текущий язык интерфейса: {{lang}}',
@ -574,13 +600,14 @@ export default {
'LLM output language not set': 'Язык вывода LLM не установлен',
'Set UI language': 'Установка языка интерфейса',
'Set LLM output language': 'Установка языка вывода LLM',
'Usage: /language ui [zh-CN|en-US]':
'Использование: /language ui [zh-CN|en-US|ru-RU]',
'Usage: /language ui [{{options}}]':
'Использование: /language ui [{{options}}]',
'Usage: /language output <language>':
'Использование: /language output <language>',
'Example: /language output 中文': 'Пример: /language output 中文',
'Example: /language output English': 'Пример: /language output English',
'Example: /language output 日本語': 'Пример: /language output 日本語',
'Example: /language output Português': 'Пример: /language output Português',
'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}',
'LLM output language set to {{lang}}':
'Язык вывода LLM установлен на {{lang}}',
@ -596,12 +623,7 @@ export default {
'To request additional UI language packs, please open an issue on GitHub.':
'Для запроса дополнительных языковых пакетов интерфейса, пожалуйста, создайте обращение на GitHub.',
'Available options:': 'Доступные варианты:',
' - zh-CN: Simplified Chinese': ' - zh-CN: Упрощенный китайский',
' - en-US: English': ' - en-US: Английский',
'Set UI language to Simplified Chinese (zh-CN)':
'Установить язык интерфейса на упрощенный китайский (zh-CN)',
'Set UI language to English (en-US)':
'Установить язык интерфейса на английский (en-US)',
'Set UI language to {{name}}': 'Установить язык интерфейса на {{name}}',
// ============================================================================
// Команды - Режим подтверждения

View file

@ -305,7 +305,9 @@ export default {
'Enable User Feedback': '启用用户反馈',
'How is Qwen doing this session? (optional)': 'Qwen 这次表现如何?(可选)',
Bad: '不满意',
Fine: '还行',
Good: '满意',
Dismiss: '忽略',
'Not Sure Yet': '暂不评价',
'Any other key': '任意其他键',
'Disable Loading Phrases': '禁用加载短语',
@ -467,6 +469,17 @@ export default {
'Either an extension name or --all must be provided':
'必须提供扩展名称或 --all',
'Lists installed extensions.': '列出已安装的扩展。',
'Path:': '路径:',
'Source:': '来源:',
'Type:': '类型:',
'Ref:': '引用:',
'Release tag:': '发布标签:',
'Enabled (User):': '已启用(用户):',
'Enabled (Workspace):': '已启用(工作区):',
'Context files:': '上下文文件:',
'Skills:': '技能:',
'Agents:': '代理:',
'MCP servers:': 'MCP 服务器:',
'Link extension failed to install.': '链接扩展安装失败。',
'Extension "{{name}}" linked successfully and enabled.':
'扩展 "{{name}}" 链接成功并已启用。',
@ -490,6 +503,18 @@ export default {
'Manage extension settings.': '管理扩展设置。',
'You need to specify a command (set or list).':
'您需要指定命令set 或 list。',
// ============================================================================
// Plugin Choice / Marketplace
// ============================================================================
'No plugins available in this marketplace.': '此市场中没有可用的插件。',
'Select a plugin to install from marketplace "{{name}}":':
'从市场 "{{name}}" 中选择要安装的插件:',
'Plugin selection cancelled.': '插件选择已取消。',
'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件',
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
'使用 ↑↓ 或 j/k 导航回车选择Esc 取消',
'{{count}} more above': '上方还有 {{count}} 项',
'{{count}} more below': '下方还有 {{count}} 项',
'manage IDE integration': '管理 IDE 集成',
'check status of IDE integration': '检查 IDE 集成状态',
'install required IDE companion for {{ideName}}':
@ -534,8 +559,8 @@ export default {
// ============================================================================
// Commands - Language
// ============================================================================
'Invalid language. Available: en-US, zh-CN':
'无效的语言。可用选项:en-US, zh-CN',
'Invalid language. Available: {{options}}':
'无效的语言。可用选项:{{options}}',
'Language subcommands do not accept additional arguments.':
'语言子命令不接受额外参数',
'Current UI language: {{lang}}': '当前 UI 语言:{{lang}}',
@ -543,11 +568,12 @@ export default {
'LLM output language not set': '未设置 LLM 输出语言',
'Set UI language': '设置 UI 语言',
'Set LLM output language': '设置 LLM 输出语言',
'Usage: /language ui [zh-CN|en-US]': '用法:/language ui [zh-CN|en-US]',
'Usage: /language ui [{{options}}]': '用法:/language ui [{{options}}]',
'Usage: /language output <language>': '用法:/language output <语言>',
'Example: /language output 中文': '示例:/language output 中文',
'Example: /language output English': '示例:/language output English',
'Example: /language output 日本語': '示例:/language output 日本語',
'Example: /language output Português': '示例:/language output Português',
'UI language changed to {{lang}}': 'UI 语言已更改为 {{lang}}',
'LLM output language set to {{lang}}': 'LLM 输出语言已设置为 {{lang}}',
'LLM output language rule file generated at {{path}}':
@ -561,11 +587,7 @@ export default {
'To request additional UI language packs, please open an issue on GitHub.':
'如需请求其他 UI 语言包,请在 GitHub 上提交 issue',
'Available options:': '可用选项:',
' - zh-CN: Simplified Chinese': ' - zh-CN: 简体中文',
' - en-US: English': ' - en-US: English',
'Set UI language to Simplified Chinese (zh-CN)':
'将 UI 语言设置为简体中文 (zh-CN)',
'Set UI language to English (en-US)': '将 UI 语言设置为英语 (en-US)',
'Set UI language to {{name}}': '将 UI 语言设置为 {{name}}',
// ============================================================================
// Commands - Approval Mode

View file

@ -35,6 +35,7 @@ export interface IControlContext {
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
inputClosed: boolean;
onInterrupt?: () => void;
}
@ -52,6 +53,7 @@ export class ControlContext implements IControlContext {
permissionMode: PermissionMode;
sdkMcpServers: Set<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
inputClosed: boolean;
onInterrupt?: () => void;
@ -71,6 +73,7 @@ export class ControlContext implements IControlContext {
this.permissionMode = options.permissionMode || 'default';
this.sdkMcpServers = new Set();
this.mcpClients = new Map();
this.inputClosed = false;
this.onInterrupt = options.onInterrupt;
}
}

View file

@ -42,6 +42,7 @@ function createMockContext(debugMode: boolean = false): IControlContext {
permissionMode: 'default',
sdkMcpServers: new Set<string>(),
mcpClients: new Map(),
inputClosed: false,
};
}
@ -637,6 +638,130 @@ describe('ControlDispatcher', () => {
});
});
describe('markInputClosed', () => {
it('should reject all pending outgoing requests when input closes', () => {
const requestId1 = 'reject-req-1';
const requestId2 = 'reject-req-2';
const resolve1 = vi.fn();
const resolve2 = vi.fn();
const reject1 = vi.fn();
const reject2 = vi.fn();
const timeoutId1 = setTimeout(() => {}, 1000);
const timeoutId2 = setTimeout(() => {}, 1000);
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const register = (
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest.bind(dispatcher);
register(requestId1, 'SystemController', resolve1, reject1, timeoutId1);
register(requestId2, 'SystemController', resolve2, reject2, timeoutId2);
dispatcher.markInputClosed();
expect(reject1).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Input closed' }),
);
expect(reject2).toHaveBeenCalledWith(
expect.objectContaining({ message: 'Input closed' }),
);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1);
expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2);
});
it('should mark input as closed on context', () => {
dispatcher.markInputClosed();
expect(mockContext.inputClosed).toBe(true);
});
it('should handle empty pending requests gracefully', () => {
expect(() => dispatcher.markInputClosed()).not.toThrow();
});
it('should be idempotent when called multiple times', () => {
const requestId = 'idempotent-req';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcher as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcher.markInputClosed();
const firstRejectCount = vi.mocked(reject).mock.calls.length;
// Call again - should not reject again
dispatcher.markInputClosed();
const secondRejectCount = vi.mocked(reject).mock.calls.length;
expect(secondRejectCount).toBe(firstRejectCount);
});
it('should log input closure in debug mode', () => {
const context = createMockContext(true);
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
const dispatcherWithDebug = new ControlDispatcher(context);
const requestId = 'reject-req-debug';
const resolve = vi.fn();
const reject = vi.fn();
const timeoutId = setTimeout(() => {}, 1000);
(
dispatcherWithDebug as unknown as {
registerOutgoingRequest: (
id: string,
controller: string,
resolve: (response: ControlResponse) => void,
reject: (error: Error) => void,
timeoutId: NodeJS.Timeout,
) => void;
}
).registerOutgoingRequest(
requestId,
'SystemController',
resolve,
reject,
timeoutId,
);
dispatcherWithDebug.markInputClosed();
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'[ControlDispatcher] Input closed, rejecting 1 pending outgoing requests',
),
);
consoleSpy.mockRestore();
});
});
describe('shutdown', () => {
it('should cancel all pending incoming requests', () => {
const requestId1 = 'shutdown-req-1';

View file

@ -207,6 +207,36 @@ export class ControlDispatcher implements IPendingRequestRegistry {
}
}
/**
* Marks stdin as closed and rejects all pending outgoing requests.
* After this is called, new outgoing requests will be rejected immediately.
* This should be called when stdin closes to avoid waiting for responses.
*/
markInputClosed(): void {
if (this.context.inputClosed) {
return; // Already marked as closed
}
this.context.inputClosed = true;
const requestIds = Array.from(this.pendingOutgoingRequests.keys());
if (this.context.debugMode) {
console.error(
`[ControlDispatcher] Input closed, rejecting ${requestIds.length} pending outgoing requests`,
);
}
// Reject all currently pending outgoing requests
for (const id of requestIds) {
const pending = this.pendingOutgoingRequests.get(id);
if (pending) {
this.deregisterOutgoingRequest(id);
pending.reject(new Error('Input closed'));
}
}
}
/**
* Stops all pending requests and cleans up all controllers
*/
@ -243,7 +273,7 @@ export class ControlDispatcher implements IPendingRequestRegistry {
}
/**
* Registers an incoming request in the pending registry
* Registers an incoming request in the pending registry.
*/
registerIncomingRequest(
requestId: string,

View file

@ -124,6 +124,11 @@ export abstract class BaseController {
timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<ControlResponse> {
// Check if stream is closed
if (this.context.inputClosed) {
throw new Error('Input closed');
}
// Check if already aborted
if (signal?.aborted) {
throw new Error('Request aborted');

View file

@ -469,21 +469,27 @@ export class PermissionController extends BaseController {
error,
);
}
// On error, use default cancel message
// Extract error message
const errorMessage =
error instanceof Error ? error.message : String(error);
// On error, pass error message as cancel message
// Only pass payload for exec and mcp types that support it
const confirmationType = toolCall.confirmationDetails.type;
if (['edit', 'exec', 'mcp'].includes(confirmationType)) {
const execOrMcpDetails = toolCall.confirmationDetails as
| ToolExecuteConfirmationDetails
| ToolMcpConfirmationDetails;
await execOrMcpDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
undefined,
);
await execOrMcpDetails.onConfirm(ToolConfirmationOutcome.Cancel, {
cancelMessage: `Error: ${errorMessage}`,
});
} else {
// For other types, don't pass payload (backward compatible)
await toolCall.confirmationDetails.onConfirm(
ToolConfirmationOutcome.Cancel,
{
cancelMessage: `Error: ${errorMessage}`,
},
);
}
} finally {

View file

@ -9,7 +9,7 @@ import type {
Config,
ServerGeminiStreamEvent,
} from '@qwen-code/qwen-code-core';
import { GeminiEventType } from '@qwen-code/qwen-code-core';
import { GeminiEventType, OutputFormat } from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { JsonOutputAdapter } from './JsonOutputAdapter.js';
@ -17,6 +17,7 @@ function createMockConfig(): Config {
return {
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getModel: vi.fn().mockReturnValue('test-model'),
getOutputFormat: vi.fn().mockReturnValue('json'),
} as unknown as Config;
}
@ -415,6 +416,79 @@ describe('JsonOutputAdapter', () => {
expect(resultMessage.num_turns).toBe(1);
});
it('should emit success result as text to stdout in text mode', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: false,
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Response text');
});
it('should emit error result to stderr in text mode', () => {
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: true,
errorMessage: 'Test error message',
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Test error message');
stderrWriteSpy.mockRestore();
});
it('should use custom summary in text mode', () => {
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: false,
summary: 'Custom summary text',
durationMs: 1000,
apiDurationMs: 800,
numTurns: 1,
});
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Custom summary text');
});
it('should handle empty error message in text mode', () => {
const stderrWriteSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.TEXT);
adapter.emitResult({
isError: true,
durationMs: 500,
apiDurationMs: 300,
numTurns: 1,
});
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
// When no errorMessage is provided, the default 'Unknown error' is used
expect(output).toBe('Unknown error');
stderrWriteSpy.mockRestore();
});
it('should emit error result', () => {
adapter.emitResult({
isError: true,

View file

@ -67,9 +67,17 @@ export class JsonOutputAdapter
);
this.messages.push(resultMessage);
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
const json = JSON.stringify(this.messages);
process.stdout.write(`${json}\n`);
if (this.config.getOutputFormat() === 'text') {
if (resultMessage.is_error) {
process.stderr.write(`${resultMessage.error?.message || ''}`);
} else {
process.stdout.write(`${resultMessage.result}`);
}
} else {
// Emit the entire messages array as JSON (includes all main agent + subagent messages)
const json = JSON.stringify(this.messages);
process.stdout.write(`${json}\n`);
}
}
emitMessage(message: CLIMessage): void {

View file

@ -153,6 +153,7 @@ describe('runNonInteractiveStreamJson', () => {
handleControlResponse: ReturnType<typeof vi.fn>;
handleCancel: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
markInputClosed: ReturnType<typeof vi.fn>;
getPendingIncomingRequestCount: ReturnType<typeof vi.fn>;
waitForPendingIncomingRequests: ReturnType<typeof vi.fn>;
sdkMcpController: {
@ -192,6 +193,7 @@ describe('runNonInteractiveStreamJson', () => {
handleControlResponse: vi.fn(),
handleCancel: vi.fn(),
shutdown: vi.fn(),
markInputClosed: vi.fn(),
getPendingIncomingRequestCount: vi.fn().mockReturnValue(0),
waitForPendingIncomingRequests: vi.fn().mockResolvedValue(undefined),
sdkMcpController: {

View file

@ -596,7 +596,14 @@ class Session {
throw streamError;
}
// Stream ended - wait for all pending work before shutdown
// Stdin closed - mark input as closed in dispatcher
// This will reject all current pending outgoing requests AND any future requests
// that might be registered by async message handlers still running
if (this.dispatcher) {
this.dispatcher.markInputClosed();
}
// Wait for all pending work before shutdown
await this.waitForAllPendingWork();
await this.shutdown();
} catch (error) {

View file

@ -228,6 +228,7 @@ describe('runNonInteractive', () => {
}
it('should process input and write text output', async () => {
setupMetricsMock();
const events: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Hello' },
{ type: GeminiEventType.Content, value: ' World' },
@ -253,13 +254,12 @@ describe('runNonInteractive', () => {
'prompt-id-1',
{ isContinuation: false },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello');
expect(processStdoutSpy).toHaveBeenCalledWith(' World');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
it('should handle a single tool call and respond', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -298,9 +298,7 @@ describe('runNonInteractive', () => {
mockConfig,
expect.objectContaining({ name: 'testTool' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
undefined,
);
// Verify first call has isContinuation: false
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
@ -319,10 +317,10 @@ describe('runNonInteractive', () => {
{ isContinuation: true },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('\n');
});
it('should handle error during tool execution and should send error back to the model', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -397,6 +395,7 @@ describe('runNonInteractive', () => {
});
it('should exit with error if sendMessageStream throws initially', async () => {
setupMetricsMock();
const apiError = new Error('API connection failed');
mockGeminiClient.sendMessageStream.mockImplementation(() => {
throw apiError;
@ -413,6 +412,7 @@ describe('runNonInteractive', () => {
});
it('should not exit if a tool is not found, and should send error back to model', async () => {
setupMetricsMock();
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
@ -464,6 +464,7 @@ describe('runNonInteractive', () => {
});
it('should exit when max session turns are exceeded', async () => {
setupMetricsMock();
vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0);
await expect(
runNonInteractive(
@ -476,6 +477,7 @@ describe('runNonInteractive', () => {
});
it('should preprocess @include commands before sending to the model', async () => {
setupMetricsMock();
// 1. Mock the imported atCommandProcessor
const { handleAtCommand } = await import(
'./ui/hooks/atCommandProcessor.js'
@ -866,6 +868,7 @@ describe('runNonInteractive', () => {
});
it('should execute a slash command that returns a prompt', async () => {
setupMetricsMock();
const mockCommand = {
name: 'testcommand',
description: 'a test command',
@ -907,6 +910,7 @@ describe('runNonInteractive', () => {
});
it('should handle command that requires confirmation by returning early', async () => {
setupMetricsMock();
const mockCommand = {
name: 'confirm',
description: 'a command that needs confirmation',
@ -925,13 +929,14 @@ describe('runNonInteractive', () => {
'prompt-id-confirm',
);
// Should write error message to stderr
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
);
});
it('should treat an unknown slash command as a regular prompt', async () => {
setupMetricsMock();
// No commands are mocked, so any slash command is "unknown"
mockGetCommands.mockReturnValue([]);
@ -965,6 +970,7 @@ describe('runNonInteractive', () => {
});
it('should handle known but unsupported slash commands like /help by returning early', async () => {
setupMetricsMock();
// Mock a built-in command that exists but is not in the allowed list
const mockHelpCommand = {
name: 'help',
@ -981,13 +987,14 @@ describe('runNonInteractive', () => {
'prompt-id-help',
);
// Should write error message to stderr
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.\n',
'The command "/help" is not supported in non-interactive mode.',
);
});
it('should handle unhandled command result types by returning early with error', async () => {
setupMetricsMock();
const mockCommand = {
name: 'noaction',
description: 'unhandled type',
@ -1007,11 +1014,12 @@ describe('runNonInteractive', () => {
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled\n',
'Unknown command result type: unhandled',
);
});
it('should pass arguments to the slash command action', async () => {
setupMetricsMock();
const mockAction = vi.fn().mockResolvedValue({
type: 'submit_prompt',
content: [{ text: 'Prompt from command' }],
@ -1825,84 +1833,4 @@ describe('runNonInteractive', () => {
{ isContinuation: false },
);
});
it('should print tool output to console in text mode (non-Task tools)', async () => {
// Test that tool output is printed to stdout in text mode
const toolCallEvent: ServerGeminiStreamEvent = {
type: GeminiEventType.ToolCallRequest,
value: {
callId: 'tool-1',
name: 'run_in_terminal',
args: { command: 'npm outdated' },
isClientInitiated: false,
prompt_id: 'prompt-id-tool-output',
},
};
// Mock tool execution with outputUpdateHandler being called
mockCoreExecuteToolCall.mockImplementation(
async (_config, _request, _signal, options) => {
// Simulate tool calling outputUpdateHandler with output chunks
if (options?.outputUpdateHandler) {
options.outputUpdateHandler('tool-1', 'Package outdated\n');
options.outputUpdateHandler('tool-1', 'npm@1.0.0 -> npm@2.0.0\n');
}
return {
responseParts: [
{
functionResponse: {
id: 'tool-1',
name: 'run_in_terminal',
response: {
output: 'Package outdated\nnpm@1.0.0 -> npm@2.0.0',
},
},
},
],
};
},
);
const firstCallEvents: ServerGeminiStreamEvent[] = [
toolCallEvent,
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
},
];
const secondCallEvents: ServerGeminiStreamEvent[] = [
{ type: GeminiEventType.Content, value: 'Dependencies checked' },
{
type: GeminiEventType.Finished,
value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } },
},
];
mockGeminiClient.sendMessageStream
.mockReturnValueOnce(createStreamFromEvents(firstCallEvents))
.mockReturnValueOnce(createStreamFromEvents(secondCallEvents));
await runNonInteractive(
mockConfig,
mockSettings,
'Check dependencies',
'prompt-id-tool-output',
);
// Verify that executeToolCall was called with outputUpdateHandler
expect(mockCoreExecuteToolCall).toHaveBeenCalledWith(
mockConfig,
expect.objectContaining({ name: 'run_in_terminal' }),
expect.any(AbortSignal),
expect.objectContaining({
outputUpdateHandler: expect.any(Function),
}),
);
// Verify tool output was written to stdout
expect(processStdoutSpy).toHaveBeenCalledWith('Package outdated\n');
expect(processStdoutSpy).toHaveBeenCalledWith('npm@1.0.0 -> npm@2.0.0\n');
expect(processStdoutSpy).toHaveBeenCalledWith('Dependencies checked');
});
});

View file

@ -4,11 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ToolCallRequestInfo,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
@ -53,19 +49,12 @@ import {
async function emitNonInteractiveFinalMessage(params: {
message: string;
isError: boolean;
adapter?: JsonOutputAdapterInterface;
adapter: JsonOutputAdapterInterface;
config: Config;
startTimeMs: number;
}): Promise<void> {
const { message, isError, adapter, config } = params;
if (!adapter) {
// Text output mode: write directly to stdout/stderr
const target = isError ? process.stderr : process.stdout;
target.write(`${message}\n`);
return;
}
// JSON output mode: emit assistant message and result
// (systemMessage should already be emitted by caller)
adapter.startAssistantMessage();
@ -122,18 +111,18 @@ export async function runNonInteractive(
): Promise<void> {
return promptIdContext.run(prompt_id, async () => {
// Create output adapter based on format
let adapter: JsonOutputAdapterInterface | undefined;
let adapter: JsonOutputAdapterInterface;
const outputFormat = config.getOutputFormat();
if (options.adapter) {
adapter = options.adapter;
} else if (outputFormat === OutputFormat.JSON) {
adapter = new JsonOutputAdapter(config);
} else if (outputFormat === OutputFormat.STREAM_JSON) {
adapter = new StreamJsonOutputAdapter(
config,
config.getIncludePartialMessages(),
);
} else {
adapter = new JsonOutputAdapter(config);
}
// Get readonly values once at the start
@ -169,14 +158,12 @@ export async function runNonInteractive(
process.on('SIGTERM', shutdownHandler);
// Emit systemMessage first (always the first message in JSON mode)
if (adapter) {
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
}
const systemMessage = await buildSystemMessage(
config,
sessionId,
permissionMode,
);
adapter.emitMessage(systemMessage);
let initialPartList: PartListUnion | null = extractPartsFromUserMessage(
options.userMessage,
@ -282,46 +269,33 @@ export async function runNonInteractive(
isFirstTurn = false;
// Start assistant message for this turn
if (adapter) {
adapter.startAssistantMessage();
}
adapter.startAssistantMessage();
for await (const event of responseStream) {
if (abortController.signal.aborted) {
handleCancellationError(config);
}
if (adapter) {
// Use adapter for all event processing
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
} else {
// Text output mode - direct stdout
if (event.type === GeminiEventType.Thought) {
process.stdout.write(event.value.description);
} else if (event.type === GeminiEventType.Content) {
process.stdout.write(event.value);
} else if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
} else if (event.type === GeminiEventType.Error) {
// Format and output the error message for text mode
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
// Use adapter for all event processing
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
toolCallRequests.push(event.value);
}
if (
outputFormat === OutputFormat.TEXT &&
event.type === GeminiEventType.Error
) {
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
// Throw error to exit with non-zero code
throw new Error(errorText);
}
}
// Finalize assistant message
if (adapter) {
adapter.finalizeAssistantMessage();
}
adapter.finalizeAssistantMessage();
totalApiDurationMs += Date.now() - apiStartTime;
if (toolCallRequests.length > 0) {
@ -350,35 +324,13 @@ export async function runNonInteractive(
: undefined;
const taskToolProgressHandler = taskToolProgress?.handler;
// Create output handler for non-Task tools in text mode (for console output)
const nonTaskOutputHandler =
!isTaskTool && !adapter
? (callId: string, outputChunk: ToolResultDisplay) => {
// Print tool output to console in text mode
if (typeof outputChunk === 'string') {
process.stdout.write(outputChunk);
} else if (
outputChunk &&
typeof outputChunk === 'object' &&
'ansiOutput' in outputChunk
) {
// Handle ANSI output - just print as string for now
process.stdout.write(String(outputChunk.ansiOutput));
}
}
: undefined;
// Combine output handlers
const outputUpdateHandler =
taskToolProgressHandler || nonTaskOutputHandler;
const toolResponse = await executeToolCall(
config,
finalRequestInfo,
abortController.signal,
outputUpdateHandler || toolCallUpdateCallback
taskToolProgressHandler || toolCallUpdateCallback
? {
...(outputUpdateHandler && { outputUpdateHandler }),
...(taskToolProgressHandler && { taskToolProgressHandler }),
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}),
@ -405,9 +357,7 @@ export async function runNonInteractive(
);
}
if (adapter) {
adapter.emitToolResult(finalRequestInfo, toolResponse);
}
adapter.emitToolResult(finalRequestInfo, toolResponse);
if (toolResponse.responseParts) {
toolResponseParts.push(...toolResponse.responseParts);
@ -415,51 +365,43 @@ export async function runNonInteractive(
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// For JSON and STREAM_JSON modes, compute usage from metrics
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: false,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
usage,
stats,
});
} else {
// Text output mode - no usage needed
process.stdout.write('\n');
}
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: false,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
usage,
stats,
});
return;
}
}
} catch (error) {
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
if (adapter) {
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: true,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
errorMessage: message,
usage,
stats,
});
}
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
const stats =
outputFormat === OutputFormat.JSON
? uiTelemetryService.getMetrics()
: undefined;
adapter.emitResult({
isError: true,
durationMs: Date.now() - startTime,
apiDurationMs: totalApiDurationMs,
numTurns: turnCount,
errorMessage: message,
usage,
stats,
});
handleError(error, config);
} finally {
process.stdout.removeListener('error', stdoutErrorHandler);

View file

@ -93,6 +93,7 @@ import {
useExtensionUpdates,
useConfirmUpdateRequests,
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
@ -176,12 +177,34 @@ export const AppContainer = (props: AppContainerProps) => {
const { addSettingInputRequest, settingInputRequests } =
useSettingInputRequests();
const { addPluginChoiceRequest, pluginChoiceRequests } =
usePluginChoiceRequests();
extensionManager.setRequestConsent(
requestConsentOrFail.bind(null, (description) =>
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
),
);
extensionManager.setRequestChoicePlugin(
(marketplace) =>
new Promise<string>((resolve, reject) => {
addPluginChoiceRequest({
marketplaceName: marketplace.name,
plugins: marketplace.plugins.map((p) => ({
name: p.name,
description: p.description,
})),
onSelect: (pluginName) => {
resolve(pluginName);
},
onCancel: () => {
reject(new Error('Plugin selection cancelled'));
},
});
}),
);
extensionManager.setRequestSetting(
(setting) =>
new Promise<string>((resolve, reject) => {
@ -411,7 +434,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Check for enforced auth type mismatch
useEffect(() => {
// Check for initialization error first
const currentAuthType = config.modelsConfig.getCurrentAuthType();
const currentAuthType = config.getModelsConfig().getCurrentAuthType();
if (
settings.merged.security?.auth?.enforcedType &&
@ -600,7 +623,7 @@ export const AppContainer = (props: AppContainerProps) => {
try {
const { memoryContent, fileCount } = await loadHierarchicalGeminiMemory(
process.cwd(),
settings.merged.context?.loadMemoryFromIncludeDirectories
settings.merged.context?.loadFromIncludeDirectories
? config.getWorkspaceContext().getDirectories()
: [],
config.getDebugMode(),
@ -1307,6 +1330,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
@ -1326,6 +1350,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
} = useFeedbackDialog({
config,
@ -1369,6 +1394,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
@ -1461,6 +1487,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
@ -1571,6 +1598,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
}),
[
@ -1611,6 +1639,7 @@ export const AppContainer = (props: AppContainerProps) => {
// Feedback dialog
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
],
);

View file

@ -5,19 +5,21 @@ import { useUIActions } from './contexts/UIActionsContext.js';
import { useUIState } from './contexts/UIStateContext.js';
import { useKeypress } from './hooks/useKeypress.js';
const FEEDBACK_OPTIONS = {
export const FEEDBACK_OPTIONS = {
GOOD: 1,
BAD: 2,
NOT_SURE: 3,
FINE: 3,
DISMISS: 0,
} as const;
const FEEDBACK_OPTION_KEYS = {
[FEEDBACK_OPTIONS.GOOD]: '1',
[FEEDBACK_OPTIONS.BAD]: '2',
[FEEDBACK_OPTIONS.NOT_SURE]: 'any',
[FEEDBACK_OPTIONS.FINE]: '3',
[FEEDBACK_OPTIONS.DISMISS]: '0',
} as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2'] as const;
export const FEEDBACK_DIALOG_KEYS = ['1', '2', '3', '0'] as const;
export const FeedbackDialog: React.FC = () => {
const uiState = useUIState();
@ -25,15 +27,19 @@ export const FeedbackDialog: React.FC = () => {
useKeypress(
(key) => {
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
// Handle keys 0-3: permanent close with feedback/dismiss
if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.BAD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.FINE);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.GOOD]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.GOOD);
} else if (key.name === FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]) {
uiActions.submitFeedback(FEEDBACK_OPTIONS.DISMISS);
} else {
uiActions.submitFeedback(FEEDBACK_OPTIONS.NOT_SURE);
// Handle other keys: temporary close
uiActions.temporaryCloseFeedbackDialog();
}
uiActions.closeFeedbackDialog();
},
{ isActive: uiState.isFeedbackDialogOpen },
);
@ -53,8 +59,16 @@ export const FeedbackDialog: React.FC = () => {
<Text color="cyan">{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.BAD]}: </Text>
<Text>{t('Bad')}</Text>
<Text> </Text>
<Text color="cyan">{t('Any other key')}: </Text>
<Text>{t('Not Sure Yet')}</Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.FINE]}:{' '}
</Text>
<Text>{t('Fine')}</Text>
<Text> </Text>
<Text color="cyan">
{FEEDBACK_OPTION_KEYS[FEEDBACK_OPTIONS.DISMISS]}:{' '}
</Text>
<Text>{t('Dismiss')}</Text>
<Text> </Text>
</Box>
</Box>
);

View file

@ -21,6 +21,8 @@ vi.mock('../../i18n/index.js', () => ({
en: 'English',
ru: 'Russian',
de: 'German',
ja: 'Japanese',
pt: 'Portuguese',
};
return map[locale] || 'English';
}),
@ -72,6 +74,7 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
// Import modules after mocking
import * as i18n from '../../i18n/index.js';
import { SUPPORTED_LANGUAGES } from '../../i18n/languages.js';
import { languageCommand } from './languageCommand.js';
import { initializeLlmOutputLanguage } from '../../utils/languageUtils.js';
@ -565,10 +568,9 @@ describe('languageCommand', () => {
it('should have nested language subcommands', () => {
const nestedNames = uiSubcommand?.subCommands?.map((c) => c.name);
expect(nestedNames).toContain('zh-CN');
expect(nestedNames).toContain('en-US');
expect(nestedNames).toContain('ru-RU');
expect(nestedNames).toContain('de-DE');
for (const lang of SUPPORTED_LANGUAGES) {
expect(nestedNames).toContain(lang.id);
}
});
it('should have action that sets language', async () => {
@ -678,6 +680,24 @@ describe('languageCommand', () => {
});
});
const jaJPSubcommand = uiSubcommand?.subCommands?.find(
(c) => c.name === 'ja-JP',
);
it('ja-JP action should set Japanese', async () => {
if (!jaJPSubcommand?.action) {
throw new Error('ja-JP subcommand must have an action.');
}
const result = await jaJPSubcommand.action(mockContext, '');
expect(i18n.setLanguageAsync).toHaveBeenCalledWith('ja');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: expect.stringContaining('UI language changed'),
});
});
it('should reject extra arguments', async () => {
if (!zhCNSubcommand?.action) {
throw new Error('zh-CN subcommand must have an action.');
@ -798,5 +818,31 @@ describe('languageCommand', () => {
'utf-8',
);
});
it('should detect Japanese locale and create Japanese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ja');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Japanese'),
'utf-8',
);
});
it('should detect Portuguese locale and create Portuguese rule file', () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(i18n.detectSystemLanguage).mockReturnValue('pt');
initializeLlmOutputLanguage();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringContaining('output-language.md'),
expect.stringContaining('Portuguese'),
'utf-8',
);
});
});
});

View file

@ -18,7 +18,10 @@ import {
type SupportedLanguage,
t,
} from '../../i18n/index.js';
import { SUPPORTED_LANGUAGES } from '../../i18n/languages.js';
import {
SUPPORTED_LANGUAGES,
getSupportedLanguageIds,
} from '../../i18n/languages.js';
import {
OUTPUT_LANGUAGE_AUTO,
isAutoLanguage,
@ -62,11 +65,14 @@ function parseUiLanguageArg(input: string): SupportedLanguage | null {
}
/**
* Formats a UI language code for display (e.g., "zh" -> "Chinesezh-CN").
* Formats a UI language code for display (e.g., "zh" -> "中文 (Chinese) [zh-CN]").
*/
function formatUiLanguageDisplay(lang: SupportedLanguage): string {
const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang);
return option ? `${option.fullName}${option.id}` : lang;
if (!option) return lang;
return option.nativeName && option.nativeName !== option.fullName
? `${option.nativeName} (${option.fullName}) [${option.id}]`
: `${option.fullName} [${option.id}]`;
}
/**
@ -219,7 +225,7 @@ export const languageCommand: SlashCommand = {
messageType: 'error',
content: [
t('Invalid command. Available subcommands:'),
` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
` - /language ui [${getSupportedLanguageIds()}] - ${t('Set UI language')}`,
` - /language output <language> - ${t('Set LLM output language')}`,
].join('\n'),
};
@ -245,7 +251,7 @@ export const languageCommand: SlashCommand = {
t('Current LLM output language: {{lang}}', { lang: outputLangDisplay }),
'',
t('Available subcommands:'),
` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`,
` /language ui [${getSupportedLanguageIds()}] - ${t('Set UI language')}`,
` /language output <language> - ${t('Set LLM output language')}`,
].join('\n'),
};
@ -274,12 +280,12 @@ export const languageCommand: SlashCommand = {
t('Set UI language'),
'',
t('Usage: /language ui [{{options}}]', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join('|'),
options: getSupportedLanguageIds(),
}),
'',
t('Available options:'),
...SUPPORTED_LANGUAGES.map(
(o) => ` - ${o.id}: ${t(o.fullName)}`,
(o) => ` - ${o.id}: ${o.nativeName || o.fullName}`,
),
'',
t(
@ -295,7 +301,7 @@ export const languageCommand: SlashCommand = {
type: 'message',
messageType: 'error',
content: t('Invalid language. Available: {{options}}', {
options: SUPPORTED_LANGUAGES.map((o) => o.id).join(','),
options: getSupportedLanguageIds(','),
}),
};
}
@ -308,7 +314,9 @@ export const languageCommand: SlashCommand = {
(lang): SlashCommand => ({
name: lang.id,
get description() {
return t('Set UI language to {{name}}', { name: lang.fullName });
return t('Set UI language to {{name}}', {
name: lang.nativeName || lang.fullName,
});
},
kind: CommandKind.BUILT_IN,
action: async (context, args) => {

View file

@ -12,6 +12,7 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
@ -147,6 +148,19 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt
key={request.marketplaceName}
marketplaceName={request.marketplaceName}
plugins={request.plugins}
onSelect={request.onSelect}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View file

@ -36,6 +36,11 @@ vi.mock('../utils/clipboardUtils.js');
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({ isFeedbackDialogOpen: false })),
}));
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: vi.fn(() => ({
temporaryCloseFeedbackDialog: vi.fn(),
})),
}));
const mockSlashCommands: SlashCommand[] = [
{
@ -376,7 +381,7 @@ describe('InputPrompt', () => {
it('should handle Ctrl+V when clipboard has an image', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
'/test/.gemini-clipboard/clipboard-123.png',
'/test/.qwen-clipboard/clipboard-123.png',
);
const { stdin, unmount } = renderWithProviders(
@ -436,7 +441,7 @@ describe('InputPrompt', () => {
it('should insert image path at cursor position with proper spacing', async () => {
const imagePath = path.join(
'test',
'.gemini-clipboard',
'.qwen-clipboard',
'clipboard-456.png',
);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);

View file

@ -37,6 +37,7 @@ import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
export interface InputPromptProps {
buffer: TextBuffer;
@ -109,6 +110,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
const uiActions = useUIActions();
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -337,12 +339,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
// Intercept feedback dialog option keys (1, 2) when dialog is open
if (
uiState.isFeedbackDialogOpen &&
(FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)
) {
return;
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
// Continue processing the key for normal input handling
}
}
// Reset ESC count and hide prompt on any non-ESC key
@ -712,6 +718,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts,
showShortcuts,
uiState,
uiActions,
],
);

View file

@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { useKeypress } from '../hooks/useKeypress.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
describe('PluginChoicePrompt', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders marketplace name in title', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test-marketplace"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('test-marketplace');
});
it('renders plugin names', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('plugin1');
expect(lastFrame()).toContain('plugin2');
});
it('renders description for selected plugin only', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin description' },
{ name: 'plugin2', description: 'Second plugin description' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// First plugin is selected by default, should show its description
expect(lastFrame()).toContain('First plugin description');
});
it('renders help text', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('↑↓');
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Escape');
});
});
describe('scrolling behavior', () => {
it('does not show scroll indicators for small lists', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).not.toContain('more below');
});
it('shows "more below" indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// At the beginning, should show "more below" but not "more above"
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).toContain('more below');
});
it('shows progress indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// Should show progress like "(1/15)"
expect(lastFrame()).toContain('(1/15)');
});
});
describe('keyboard navigation', () => {
it('registers keypress handler', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
});
it('calls onCancel when escape is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
expect(onCancel).toHaveBeenCalled();
});
it('calls onSelect with plugin name when enter is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'test-plugin' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'return', sequence: '\r' } as never);
expect(onSelect).toHaveBeenCalledWith('test-plugin');
});
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: '2', sequence: '2' } as never);
expect(onSelect).toHaveBeenCalledWith('plugin2');
});
});
describe('selection indicator', () => {
it('shows selection indicator for first plugin by default', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('');
});
});
});

View file

@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState, useCallback, useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
interface PluginChoice {
name: string;
description?: string;
}
type PluginChoicePromptProps = {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
terminalWidth: number;
};
// Maximum number of visible items in the list
const MAX_VISIBLE_ITEMS = 8;
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
const { marketplaceName, plugins, onSelect, onCancel } = props;
const [selectedIndex, setSelectedIndex] = useState(0);
const prefixWidth = 2; // " " or " "
const handleKeypress = useCallback(
(key: Key) => {
const { name, sequence } = key;
if (name === 'escape') {
onCancel();
return;
}
if (name === 'return') {
const plugin = plugins[selectedIndex];
if (plugin) {
onSelect(plugin.name);
}
return;
}
// Navigate up
if (name === 'up' || sequence === 'k') {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
return;
}
// Navigate down
if (name === 'down' || sequence === 'j') {
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
return;
}
// Number shortcuts (1-9)
const num = parseInt(sequence || '', 10);
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
setSelectedIndex(num - 1);
const plugin = plugins[num - 1];
if (plugin) {
onSelect(plugin.name);
}
}
},
[plugins, selectedIndex, onSelect, onCancel],
);
useKeypress(handleKeypress, { isActive: true });
// Calculate visible range for scrolling
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
const total = plugins.length;
if (total <= MAX_VISIBLE_ITEMS) {
return {
visiblePlugins: plugins,
startIndex: 0,
hasMore: false,
hasLess: false,
};
}
// Calculate window position to keep selected item visible
let start = 0;
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
if (selectedIndex <= halfWindow) {
// Near the beginning
start = 0;
} else if (selectedIndex >= total - halfWindow) {
// Near the end
start = total - MAX_VISIBLE_ITEMS;
} else {
// In the middle - center on selected
start = selectedIndex - halfWindow;
}
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
return {
visiblePlugins: plugins.slice(start, end),
startIndex: start,
hasLess: start > 0,
hasMore: end < total,
};
}, [plugins, selectedIndex]);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
width="100%"
>
<Text bold color={theme.text.accent}>
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
</Text>
<Box marginTop={1} flexDirection="column">
{/* Show "more items above" indicator */}
{hasLess && (
<Box>
<Text dimColor>
{' '}
{t('{{count}} more above', { count: String(startIndex) })}
</Text>
</Box>
)}
{visiblePlugins.map((plugin, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const prefix = isSelected ? ' ' : ' ';
return (
<Box key={plugin.name} flexDirection="column">
<Box flexDirection="row">
<Text color={isSelected ? theme.text.accent : undefined}>
{prefix}
</Text>
<Text
bold={isSelected}
color={isSelected ? theme.text.accent : undefined}
>
{plugin.name}
</Text>
</Box>
{/* Show full description only for selected item */}
{isSelected && plugin.description && (
<Box marginLeft={prefixWidth}>
<Text color={theme.text.accent}>{plugin.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Show "more items below" indicator */}
{hasMore && (
<Box>
<Text dimColor>
{' '}
{' '}
{t('{{count}} more below', {
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
})}
</Text>
</Box>
)}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
</Text>
{plugins.length > MAX_VISIBLE_ITEMS && (
<Text dimColor>
({selectedIndex + 1}/{plugins.length})
</Text>
)}
</Box>
</Box>
);
};

View file

@ -1368,7 +1368,7 @@ describe('SettingsDialog', () => {
enabled: true,
},
context: {
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
fileFiltering: {
respectGitIgnore: true,
respectQwenIgnore: true,
@ -1540,7 +1540,7 @@ describe('SettingsDialog', () => {
enableRecursiveFileSearch: false,
disableFuzzySearch: true,
},
loadMemoryFromIncludeDirectories: true,
loadFromIncludeDirectories: true,
},
});
const onSelect = vi.fn();
@ -1605,7 +1605,7 @@ describe('SettingsDialog', () => {
enabled: false,
},
context: {
loadMemoryFromIncludeDirectories: false,
loadFromIncludeDirectories: false,
fileFiltering: {
respectGitIgnore: false,
respectQwenIgnore: false,

View file

@ -260,6 +260,7 @@ def fibonacci(n):
availableTerminalHeight={diffHeight}
contentWidth={colorizeCodeWidth}
theme={previewTheme}
settings={settings}
/>
</Box>
);

View file

@ -9,6 +9,15 @@ import { render } from 'ink-testing-library';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
import { vi } from 'vitest';
import type { LoadedSettings } from '../../../config/settings.js';
const mockSettings: LoadedSettings = {
merged: {
ui: {
showLineNumbers: true,
},
},
} as LoadedSettings;
describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
const mockColorizeCode = vi.spyOn(CodeColorizer, 'colorizeCode');
@ -17,8 +26,8 @@ describe('<OverflowProvider><DiffRenderer /></OverflowProvider>', () => {
mockColorizeCode.mockClear();
});
const sanitizeOutput = (output: string | undefined, terminalWidth: number) =>
output?.replace(/GAP_INDICATOR/g, '═'.repeat(terminalWidth));
const sanitizeOutput = (output: string | undefined, contentWidth: number) =>
output?.replace(/GAP_INDICATOR/g, '═'.repeat(contentWidth));
it('should call colorizeCode with correct language for new file with known extension', () => {
const newFileDiffContent = `
@ -36,6 +45,7 @@ index 0000000..e69de29
diffContent={newFileDiffContent}
filename="test.py"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -45,6 +55,7 @@ index 0000000..e69de29
undefined,
80,
undefined,
mockSettings,
);
});
@ -64,6 +75,7 @@ index 0000000..e69de29
diffContent={newFileDiffContent}
filename="test.unknown"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -73,6 +85,7 @@ index 0000000..e69de29
undefined,
80,
undefined,
mockSettings,
);
});
@ -88,7 +101,11 @@ index 0000000..e69de29
`;
render(
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} contentWidth={80} />
<DiffRenderer
diffContent={newFileDiffContent}
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
expect(mockColorizeCode).toHaveBeenCalledWith(
@ -97,6 +114,7 @@ index 0000000..e69de29
undefined,
80,
undefined,
mockSettings,
);
});
@ -116,6 +134,7 @@ index 0000001..0000002 100644
diffContent={existingFileDiffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -146,6 +165,7 @@ index 1234567..1234567 100644
diffContent={noChangeDiff}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -156,7 +176,11 @@ index 1234567..1234567 100644
it('should handle empty diff content', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer diffContent="" contentWidth={80} />
<DiffRenderer
diffContent=""
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
expect(lastFrame()).toContain('No diff content');
@ -183,6 +207,7 @@ index 123..456 100644
diffContent={diffWithGap}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -220,6 +245,7 @@ index abc..def 100644
diffContent={diffWithSmallGap}
filename="file.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -251,7 +277,7 @@ index 123..789 100644
it.each([
{
terminalWidth: 80,
contentWidth: 80,
height: undefined,
expected: ` 1 console.log('first hunk');
2 - const oldVar = 1;
@ -264,7 +290,7 @@ index 123..789 100644
22 console.log('end of second hunk');`,
},
{
terminalWidth: 80,
contentWidth: 80,
height: 6,
expected: `... first 4 lines hidden ...
@ -274,7 +300,7 @@ index 123..789 100644
22 console.log('end of second hunk');`,
},
{
terminalWidth: 30,
contentWidth: 30,
height: 6,
expected: `... first 10 lines hidden ...
;
@ -284,20 +310,21 @@ index 123..789 100644
second hunk');`,
},
])(
'with terminalWidth $terminalWidth and height $height',
({ terminalWidth, height, expected }) => {
'with contentWidth $contentWidth and height $height',
({ contentWidth, height, expected }) => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffWithMultipleHunks}
filename="multi.js"
contentWidth={terminalWidth}
contentWidth={contentWidth}
availableTerminalHeight={height}
settings={mockSettings}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(sanitizeOutput(output, terminalWidth)).toEqual(expected);
expect(sanitizeOutput(output, contentWidth)).toEqual(expected);
},
);
});
@ -324,6 +351,7 @@ fileDiff Index: file.txt
diffContent={newFileDiff}
filename="TEST"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -354,6 +382,7 @@ fileDiff Index: Dockerfile
diffContent={newFileDiff}
filename="Dockerfile"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
@ -362,4 +391,86 @@ fileDiff Index: Dockerfile
2 RUN npm install
3 RUN npm run build`);
});
describe('showLineNumbers setting', () => {
const diffContent = `
diff --git a/test.txt b/test.txt
index 0000001..0000002 100644
--- a/test.txt
+++ b/test.txt
@@ -1,2 +1,2 @@
-old line 1
+new line 1
context line 2
`;
it('should show line numbers by default when settings is undefined', () => {
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toContain('1 -');
expect(output).toContain('1 +');
expect(output).toContain('2 ');
});
it('should show line numbers when showLineNumbers is true', () => {
const mockSettings = {
merged: {
ui: {
showLineNumbers: true,
},
},
} as unknown as LoadedSettings;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
const output = lastFrame();
expect(output).toContain('1 -');
expect(output).toContain('1 +');
expect(output).toContain('2 ');
});
it('should hide line numbers when showLineNumbers is false', () => {
const mockSettings = {
merged: {
ui: {
showLineNumbers: false,
},
},
} as unknown as LoadedSettings;
const { lastFrame } = render(
<OverflowProvider>
<DiffRenderer
diffContent={diffContent}
filename="test.txt"
contentWidth={80}
settings={mockSettings}
/>
</OverflowProvider>,
);
const output = lastFrame();
// Line numbers should not be present
expect(output).not.toMatch(/^\s*\d+\s*[-+]/m);
// But the content should still be there
expect(output).toContain('old line 1');
expect(output).toContain('new line 1');
expect(output).toContain('context line 2');
});
});
});

View file

@ -11,6 +11,7 @@ import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme as semanticTheme } from '../../semantic-colors.js';
import type { Theme } from '../../themes/theme.js';
import type { LoadedSettings } from '../../../config/settings.js';
interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
@ -86,6 +87,7 @@ interface DiffRendererProps {
availableTerminalHeight?: number;
contentWidth: number;
theme?: Theme;
settings?: LoadedSettings;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@ -97,6 +99,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
contentWidth,
theme,
settings,
}) => {
const screenReaderEnabled = useIsScreenReaderEnabled();
if (!diffContent || typeof diffContent !== 'string') {
@ -157,6 +160,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
contentWidth,
theme,
settings,
);
} else {
renderedOutput = renderDiffContent(
@ -165,6 +169,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
tabWidth,
availableTerminalHeight,
contentWidth,
settings,
);
}
@ -177,6 +182,7 @@ const renderDiffContent = (
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
contentWidth: number,
settings?: LoadedSettings,
) => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
@ -201,6 +207,8 @@ const renderDiffContent = (
);
}
const showLineNumbers = settings?.merged.ui?.showLineNumbers ?? true;
const maxLineNumber = Math.max(
0,
...displayableLines.map((l) => l.oldLine ?? 0),
@ -299,18 +307,20 @@ const renderDiffContent = (
acc.push(
<Box key={lineKey} flexDirection="row">
<Text
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
{showLineNumbers && (
<Text
color={semanticTheme.text.secondary}
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined
}
>
{gutterNumStr.padStart(gutterWidth)}{' '}
</Text>
)}
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>

View file

@ -226,6 +226,7 @@ export const ToolConfirmationMessage: React.FC<
filename={confirmationDetails.fileName}
availableTerminalHeight={availableBodyContentHeight()}
contentWidth={contentWidth}
settings={settings}
/>
);
} else if (confirmationDetails.type === 'exec') {

View file

@ -11,11 +11,13 @@ import { ToolMessage } from './ToolMessage.js';
import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
import type {
AnsiOutput,
AnsiOutputDisplay,
Config,
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../../config/settings.js';
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
@ -58,10 +60,17 @@ vi.mock('../GeminiRespondingSpinner.js', () => ({
vi.mock('./DiffRenderer.js', () => ({
DiffRenderer: function MockDiffRenderer({
diffContent,
settings,
}: {
diffContent: string;
settings?: unknown;
}) {
return <Text>MockDiff:{diffContent}</Text>;
return (
<Text>
MockDiff:{diffContent}
{settings ? ':withSettings' : ''}
</Text>
);
},
}));
vi.mock('../../utils/MarkdownDisplay.js', () => ({
@ -83,6 +92,15 @@ vi.mock('../subagents/index.js', () => ({
},
}));
// Mock settings
const mockSettings: LoadedSettings = {
merged: {
ui: {
showLineNumbers: true,
},
},
} as LoadedSettings;
// Helper to render with context
const renderWithContext = (
ui: React.ReactElement,
@ -90,9 +108,11 @@ const renderWithContext = (
) => {
const contextValue: StreamingState = streamingState;
return render(
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>,
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
};

View file

@ -30,6 +30,8 @@ import {
TOOL_STATUS,
} from '../../constants.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../../config/settings.js';
const STATIC_HEIGHT = 1;
const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc.
@ -210,12 +212,14 @@ const DiffResultRenderer: React.FC<{
data: { fileDiff: string; fileName: string };
availableHeight?: number;
childWidth: number;
}> = ({ data, availableHeight, childWidth }) => (
settings?: LoadedSettings;
}> = ({ data, availableHeight, childWidth, settings }) => (
<DiffRenderer
diffContent={data.fileDiff}
filename={data.fileName}
availableTerminalHeight={availableHeight}
contentWidth={childWidth}
settings={settings}
/>
);
@ -243,6 +247,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
}) => {
const settings = useSettings();
const isThisShellFocused =
(name === SHELL_COMMAND_NAME || name === 'Shell') &&
status === ToolCallStatus.Executing &&
@ -348,6 +353,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
data={displayRenderer.data}
availableHeight={availableHeight}
childWidth={innerWidth}
settings={settings}
/>
)}
{displayRenderer.type === 'ansi' && (

View file

@ -71,6 +71,7 @@ export interface UIActions {
// Feedback dialog
openFeedbackDialog: () => void;
closeFeedbackDialog: () => void;
temporaryCloseFeedbackDialog: () => void;
submitFeedback: (rating: number) => void;
}

View file

@ -15,6 +15,7 @@ import type {
HistoryItemWithoutId,
StreamingState,
SettingInputRequest,
PluginChoiceRequest,
} from '../types.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
@ -61,6 +62,7 @@ export interface UIState {
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;

View file

@ -13,6 +13,7 @@ import {
useExtensionUpdates,
useSettingInputRequests,
useConfirmUpdateRequests,
usePluginChoiceRequests,
} from './useExtensionUpdates.js';
import {
QWEN_DIR,
@ -490,3 +491,118 @@ describe('useExtensionUpdates', () => {
});
});
});
describe('usePluginChoiceRequests', () => {
it('should add a plugin choice request', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'test-marketplace',
);
expect(result.current.pluginChoiceRequests[0].plugins).toHaveLength(2);
});
it('should remove a plugin choice request when a plugin is selected', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Select a plugin
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onSelect).toHaveBeenCalledWith('plugin1');
expect(onCancel).not.toHaveBeenCalled();
});
it('should remove a plugin choice request when cancelled', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Cancel the request
act(() => {
result.current.pluginChoiceRequests[0].onCancel();
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it('should handle multiple plugin choice requests', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect1 = vi.fn();
const onCancel1 = vi.fn();
const onSelect2 = vi.fn();
const onCancel2 = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-1',
plugins: [{ name: 'plugin1' }],
onSelect: onSelect1,
onCancel: onCancel1,
});
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-2',
plugins: [{ name: 'plugin2' }],
onSelect: onSelect2,
onCancel: onCancel2,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(2);
// Select from first request
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'marketplace-2',
);
expect(onSelect1).toHaveBeenCalledWith('plugin1');
});
});

View file

@ -17,6 +17,7 @@ import {
MessageType,
type ConfirmationRequest,
type SettingInputRequest,
type PluginChoiceRequest,
} from '../types.js';
import { checkExhaustive } from '../../utils/checks.js';
@ -144,6 +145,71 @@ export const useSettingInputRequests = () => {
};
};
type PluginChoiceRequestWrapper = {
marketplaceName: string;
plugins: Array<{ name: string; description?: string }>;
onSelect: (pluginName: string) => void;
onCancel: () => void;
};
type PluginChoiceRequestAction =
| { type: 'add'; request: PluginChoiceRequestWrapper }
| { type: 'remove'; request: PluginChoiceRequestWrapper };
function pluginChoiceRequestsReducer(
state: PluginChoiceRequestWrapper[],
action: PluginChoiceRequestAction,
): PluginChoiceRequestWrapper[] {
switch (action.type) {
case 'add':
return [...state, action.request];
case 'remove':
return state.filter((r) => r !== action.request);
default:
checkExhaustive(action);
return state;
}
}
export const usePluginChoiceRequests = () => {
const [pluginChoiceRequests, dispatchPluginChoiceRequests] = useReducer(
pluginChoiceRequestsReducer,
[],
);
const addPluginChoiceRequest = useCallback(
(original: PluginChoiceRequest) => {
const wrappedRequest: PluginChoiceRequestWrapper = {
marketplaceName: original.marketplaceName,
plugins: original.plugins,
onSelect: (pluginName: string) => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onSelect(pluginName);
},
onCancel: () => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onCancel();
},
};
dispatchPluginChoiceRequests({
type: 'add',
request: wrappedRequest,
});
},
[dispatchPluginChoiceRequests],
);
return {
addPluginChoiceRequest,
pluginChoiceRequests,
dispatchPluginChoiceRequests,
};
};
export const useExtensionUpdates = (
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],

View file

@ -15,6 +15,7 @@ import {
USER_SETTINGS_PATH,
} from '../../config/settings.js';
import type { SessionStatsState } from '../contexts/SessionContext.js';
import { FEEDBACK_OPTIONS } from '../FeedbackDialog.js';
import stripJsonComments from 'strip-json-comments';
const FEEDBACK_SHOW_PROBABILITY = 0.25; // 25% probability of showing feedback dialog
@ -96,37 +97,48 @@ export const useFeedbackDialog = ({
}: UseFeedbackDialogProps) => {
// Feedback dialog state
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [isFeedbackDismissedTemporarily, setIsFeedbackDismissedTemporarily] =
useState(false);
const openFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(true);
// Record the timestamp when feedback dialog is shown (fire and forget)
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
}, [settings]);
}, []);
const closeFeedbackDialog = useCallback(
() => setIsFeedbackDialogOpen(false),
[],
);
const temporaryCloseFeedbackDialog = useCallback(() => {
setIsFeedbackDialogOpen(false);
setIsFeedbackDismissedTemporarily(true);
}, []);
const submitFeedback = useCallback(
(rating: number) => {
// Create and log the feedback event
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
// Only create and log feedback event for ratings 1-3 (GOOD, BAD, FINE)
// Rating 0 (DISMISS) should not trigger any telemetry
if (rating >= FEEDBACK_OPTIONS.GOOD && rating <= FEEDBACK_OPTIONS.FINE) {
const feedbackEvent = new UserFeedbackEvent(
sessionStats.sessionId,
rating as UserFeedbackRating,
config.getModel(),
config.getApprovalMode(),
);
logUserFeedback(config, feedbackEvent);
}
// Record the timestamp when feedback dialog is submitted
settings.setValue(
SettingScope.User,
'ui.feedbackLastShownTimestamp',
Date.now(),
);
logUserFeedback(config, feedbackEvent);
closeFeedbackDialog();
},
[config, sessionStats, closeFeedbackDialog],
[closeFeedbackDialog, sessionStats.sessionId, config, settings],
);
useEffect(() => {
@ -140,13 +152,15 @@ export const useFeedbackDialog = ({
// 5. Random chance (25% probability)
// 6. Meets minimum requirements (tool calls > 10 OR user messages > 5)
// 7. Fatigue mechanism allows showing (not shown recently across sessions)
// 8. Not temporarily dismissed
if (
config.getAuthType() !== AuthType.QWEN_OAUTH ||
!config.getUsageStatisticsEnabled() ||
settings.merged.ui?.enableUserFeedback === false ||
!lastMessageIsAIResponse(history) ||
Math.random() > FEEDBACK_SHOW_PROBABILITY ||
!meetsMinimumSessionRequirements(sessionStats)
!meetsMinimumSessionRequirements(sessionStats) ||
isFeedbackDismissedTemporarily
) {
return;
}
@ -164,15 +178,27 @@ export const useFeedbackDialog = ({
history,
sessionStats,
isFeedbackDialogOpen,
isFeedbackDismissedTemporarily,
openFeedbackDialog,
settings.merged.ui?.enableUserFeedback,
config,
]);
// Reset temporary dismissal when a new AI response starts streaming
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
isFeedbackDismissedTemporarily
) {
setIsFeedbackDismissedTemporarily(false);
}
}, [streamingState, isFeedbackDismissedTemporarily]);
return {
isFeedbackDialogOpen,
openFeedbackDialog,
closeFeedbackDialog,
temporaryCloseFeedbackDialog,
submitFeedback,
};
};

View file

@ -422,3 +422,15 @@ export interface SettingInputRequest {
onSubmit: (value: string) => void;
onCancel: () => void;
}
export interface PluginChoice {
name: string;
description?: string;
}
export interface PluginChoiceRequest {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
}

View file

@ -52,7 +52,7 @@ export async function saveClipboardImage(
// Create a temporary directory for clipboard images within the target directory
// This avoids security restrictions on paths outside the target directory
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const tempDir = path.join(baseDir, '.qwen-clipboard');
await fs.mkdir(tempDir, { recursive: true });
// Generate a unique filename with timestamp
@ -130,7 +130,7 @@ export async function cleanupOldClipboardImages(
): Promise<void> {
try {
const baseDir = targetDir || process.cwd();
const tempDir = path.join(baseDir, '.gemini-clipboard');
const tempDir = path.join(baseDir, '.qwen-clipboard');
const files = await fs.readdir(tempDir);
const oneHourAgo = Date.now() - 60 * 60 * 1000;

View file

@ -984,26 +984,6 @@ describe('createTaskToolProgressHandler', () => {
expect(mockAdapter.emitToolResult).not.toHaveBeenCalled();
});
it('should work without adapter (non-JSON mode)', () => {
const { handler } = createTaskToolProgressHandler(
mockConfig,
'parent-tool-id',
undefined,
);
const taskDisplay: TaskResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
taskPrompt: 'Test prompt',
status: 'running',
toolCalls: [],
};
// Should not throw
expect(() => handler('task-call-id', taskDisplay)).not.toThrow();
});
it('should work with adapter that does not support subagent APIs', () => {
const limitedAdapter = {
emitToolResult: vi.fn(),

View file

@ -306,7 +306,7 @@ export async function buildSystemMessage(
export function createTaskToolProgressHandler(
config: Config,
taskToolCallId: string,
adapter: JsonOutputAdapterInterface | undefined,
adapter: JsonOutputAdapterInterface,
): {
handler: OutputUpdateHandler;
} {
@ -406,7 +406,7 @@ export function createTaskToolProgressHandler(
toolCallToEmit.status === 'executing' ||
toolCallToEmit.status === 'awaiting_approval'
) {
if (adapter?.processSubagentToolCall) {
if (adapter.processSubagentToolCall) {
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
emittedToolUseIds.add(toolCall.callId);
}
@ -432,19 +432,17 @@ export function createTaskToolProgressHandler(
// Mark as emitted even if we skip, to prevent duplicate emits
emittedToolResultIds.add(toolCall.callId);
if (adapter) {
const request = buildRequest(toolCall);
const response = buildResponse(toolCall);
// For subagent tool results, we need to pass parentToolUseId
// The adapter implementations accept an optional parentToolUseId parameter
if (
'emitToolResult' in adapter &&
typeof adapter.emitToolResult === 'function'
) {
adapter.emitToolResult(request, response, taskToolCallId);
} else {
adapter.emitToolResult(request, response);
}
const request = buildRequest(toolCall);
const response = buildResponse(toolCall);
// For subagent tool results, we need to pass parentToolUseId
// The adapter implementations accept an optional parentToolUseId parameter
if (
'emitToolResult' in adapter &&
typeof adapter.emitToolResult === 'function'
) {
adapter.emitToolResult(request, response, taskToolCallId);
} else {
adapter.emitToolResult(request, response);
}
};
@ -501,12 +499,6 @@ export function createTaskToolProgressHandler(
const taskDisplay = outputChunk as TaskResultDisplay;
const previous = previousTaskStates.get(callId);
// If no adapter, just track state (for non-JSON modes)
if (!adapter) {
previousTaskStates.set(callId, taskDisplay);
return;
}
// Only process if adapter supports subagent APIs
if (
!adapter.processSubagentToolCall ||

View file

@ -14,18 +14,24 @@ import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.
import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js';
import * as cleanupModule from './utils/cleanup.js';
type ModelsConfig = ReturnType<Config['getModelsConfig']>;
// Helper to create a mock Config with modelsConfig
function createMockConfig(overrides?: Partial<Config>): Config {
return {
const baseModelsConfig = {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
} as unknown as ModelsConfig;
const baseConfig: Partial<Config> = {
refreshAuth: vi.fn().mockResolvedValue('refreshed'),
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT),
getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: undefined }),
modelsConfig: {
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
getModelsConfig: vi.fn().mockReturnValue(baseModelsConfig),
};
return {
...baseConfig,
...overrides,
} as unknown as Config;
} as Config;
}
describe('validateNonInterActiveAuth', () => {
@ -128,10 +134,10 @@ describe('validateNonInterActiveAuth', () => {
);
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
await validateNonInteractiveAuth(
@ -153,10 +159,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-openai-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -169,10 +175,10 @@ describe('validateNonInterActiveAuth', () => {
it('uses configured QWEN_OAUTH if provided', async () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -222,7 +228,7 @@ describe('validateNonInterActiveAuth', () => {
expect(validateAuthMethodSpy).not.toHaveBeenCalled();
expect(consoleErrorSpy).not.toHaveBeenCalled();
expect(processExitSpy).not.toHaveBeenCalled();
// refreshAuth is called with the authType from config.modelsConfig.getCurrentAuthType()
// refreshAuth is called with the authType from config.getModelsConfig().getCurrentAuthType()
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.QWEN_OAUTH);
});
@ -233,10 +239,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
await validateNonInteractiveAuth(
undefined,
@ -251,10 +257,10 @@ describe('validateNonInterActiveAuth', () => {
process.env['OPENAI_API_KEY'] = 'fake-key';
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
await validateNonInteractiveAuth(
@ -297,10 +303,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
@ -334,10 +340,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -373,10 +379,10 @@ describe('validateNonInterActiveAuth', () => {
const nonInteractiveConfig = createMockConfig({
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -433,10 +439,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.QWEN_OAUTH),
},
}),
});
try {
@ -471,10 +477,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {
@ -511,10 +517,10 @@ describe('validateNonInterActiveAuth', () => {
refreshAuth: refreshAuthMock,
getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON),
getIncludePartialMessages: vi.fn().mockReturnValue(false),
modelsConfig: {
getModelsConfig: vi.fn().mockReturnValue({
getModel: vi.fn().mockReturnValue('default-model'),
getCurrentAuthType: vi.fn().mockReturnValue(AuthType.USE_OPENAI),
},
}),
});
try {

View file

@ -19,7 +19,9 @@ export async function validateNonInteractiveAuth(
): Promise<Config> {
try {
// Get the actual authType from config which has already resolved CLI args, env vars, and settings
const authType = nonInteractiveConfig.modelsConfig.getCurrentAuthType();
const authType = nonInteractiveConfig
.getModelsConfig()
.getCurrentAuthType();
if (!authType) {
throw new Error(
'No auth type is selected. Please configure an auth type (e.g. via settings or `--auth-type`) before running in non-interactive mode.',