Merge remote-tracking branch 'origin/main' into fix/pr2371-btw-complete

# Conflicts:
#	packages/cli/src/ui/AppContainer.tsx
#	packages/cli/src/ui/hooks/useGeminiStream.ts
#	packages/cli/src/ui/layouts/DefaultAppLayout.tsx
#	packages/cli/src/ui/types.ts
#	packages/core/src/core/client.test.ts
This commit is contained in:
yiliang114 2026-03-20 00:55:29 +08:00
commit bd77eef46f
406 changed files with 55514 additions and 6431 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.12.3",
"version": "0.13.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@ -33,7 +33,7 @@
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.3"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.14.1",

View file

@ -58,11 +58,11 @@ import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import type { ApprovalModeValue } from './session/types.js';
import { z } from 'zod';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { Session } from './session/Session.js';
import type { ApprovalModeValue } from './session/types.js';
import { formatAcpModelId } from '../utils/acpModelUtils.js';
const debugLogger = createDebugLogger('ACP_AGENT');

View file

@ -13,12 +13,10 @@ const RESOURCE_NOT_FOUND_CODE = -32002;
const INTERNAL_ERROR_CODE = -32603;
const createFallback = (): FileSystemService => ({
readTextFile: vi
.fn()
.mockResolvedValue({
content: '',
_meta: { bom: false, encoding: 'utf-8' },
}),
readTextFile: vi.fn().mockResolvedValue({
content: '',
_meta: { bom: false, encoding: 'utf-8' },
}),
writeTextFile: vi.fn().mockResolvedValue({ _meta: undefined }),
findFiles: vi.fn().mockReturnValue([]),
});

View file

@ -16,7 +16,7 @@ import type {
ToolCallConfirmationDetails,
ToolResult,
ChatRecord,
SubAgentEventEmitter,
AgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
@ -34,6 +34,7 @@ import {
TodoWriteTool,
ExitPlanModeTool,
readManyFiles,
ToolNames,
} from '@qwen-code/qwen-code-core';
import { RequestError } from '@agentclientprotocol/sdk';
@ -90,6 +91,14 @@ const debugLogger = createDebugLogger('SESSION');
*/
export class Session implements SessionContext {
private pendingPrompt: AbortController | null = null;
/**
* Tracks the completion of the current prompt so that the next prompt
* can await it. This prevents a new prompt from reading chat history
* before the previous prompt's tool results have been added
* a race condition that causes malformed history on Windows where
* process termination is slow.
*/
private pendingPromptCompletion: Promise<void> | null = null;
private turn: number = 0;
// Modular components
@ -143,10 +152,43 @@ export class Session implements SessionContext {
}
async prompt(params: PromptRequest): Promise<PromptResponse> {
// Install this prompt's AbortController before awaiting the previous
// prompt, so that a session/cancel during the wait targets us.
this.pendingPrompt?.abort();
const pendingSend = new AbortController();
this.pendingPrompt = pendingSend;
// Wait for the previous prompt to finish so chat history is consistent.
if (this.pendingPromptCompletion) {
try {
await this.pendingPromptCompletion;
} catch {
// Expected: previous prompt was cancelled or errored
}
}
// Cancelled while waiting for the previous prompt to finish.
if (pendingSend.signal.aborted) {
return { stopReason: 'cancelled' };
}
// Track this prompt's completion for the next prompt to await
let resolveCompletion!: () => void;
this.pendingPromptCompletion = new Promise<void>((resolve) => {
resolveCompletion = resolve;
});
try {
return await this.#executePrompt(params, pendingSend);
} finally {
resolveCompletion();
}
}
async #executePrompt(
params: PromptRequest,
pendingSend: AbortController,
): Promise<PromptResponse> {
// Increment turn counter for each user prompt
this.turn += 1;
@ -489,7 +531,7 @@ export class Session implements SessionContext {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
eventEmitter: AgentEventEmitter;
}
).eventEmitter;
@ -498,7 +540,7 @@ export class Session implements SessionContext {
const subagentType = (args['subagent_type'] as string) ?? '';
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(
const subSubAgentTracker = new SubAgentTracker(
this,
this.client,
parentToolCallId,
@ -506,24 +548,23 @@ export class Session implements SessionContext {
);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
subAgentCleanupFunctions = subSubAgentTracker.setup(
taskEventEmitter,
abortSignal,
);
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
// Use the new permission flow: getDefaultPermission + getConfirmationDetails
// ask_user_question must always go through confirmation even in YOLO mode
// so the user always has a chance to respond to questions.
const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION;
const defaultPermission =
this.config.getApprovalMode() !== ApprovalMode.YOLO ||
isAskUserQuestionTool
? await invocation.getDefaultPermission()
: 'allow';
// In YOLO mode, auto-approve everything except ask_user_question
// (the user must always have a chance to respond to questions)
const isAskUserQuestionTool =
confirmationDetails && confirmationDetails.type === 'ask_user_question';
const effectiveConfirmationDetails =
this.config.getApprovalMode() === ApprovalMode.YOLO &&
!isAskUserQuestionTool
? false
: confirmationDetails;
const needsConfirmation = defaultPermission === 'ask';
// Check for plan mode enforcement - block non-read-only tools
// but allow ask_user_question so users can answer clarification questions
@ -532,7 +573,7 @@ export class Session implements SessionContext {
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
effectiveConfirmationDetails
needsConfirmation
) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
@ -543,25 +584,35 @@ export class Session implements SessionContext {
);
}
if (effectiveConfirmationDetails) {
if (defaultPermission === 'deny') {
return errorResponse(
new Error(
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
),
);
}
if (needsConfirmation) {
const confirmationDetails =
await invocation.getConfirmationDetails(abortSignal);
const content: ToolCallContent[] = [];
if (effectiveConfirmationDetails.type === 'edit') {
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: effectiveConfirmationDetails.fileName,
oldText: effectiveConfirmationDetails.originalContent,
newText: effectiveConfirmationDetails.newContent,
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
// Add plan content for exit_plan_mode
if (effectiveConfirmationDetails.type === 'plan') {
if (confirmationDetails.type === 'plan') {
content.push({
type: 'content',
content: {
type: 'text',
text: effectiveConfirmationDetails.plan,
text: confirmationDetails.plan,
},
});
}
@ -571,7 +622,7 @@ export class Session implements SessionContext {
const params: RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(effectiveConfirmationDetails),
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
@ -595,7 +646,7 @@ export class Session implements SessionContext {
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await effectiveConfirmationDetails.onConfirm(outcome, {
await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
});
@ -611,6 +662,8 @@ export class Session implements SessionContext {
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
@ -1000,8 +1053,13 @@ function toPermissionOptions(
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -1009,13 +1067,13 @@ function toPermissionOptions(
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.toolName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -1023,8 +1081,13 @@ function toPermissionOptions(
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user`,
kind: 'allow_always',
},
...basicPermissionOptions,

View file

@ -10,26 +10,26 @@ import type { SessionContext } from './types.js';
import type {
Config,
ToolRegistry,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
TodoWriteTool,
} from '@qwen-code/qwen-code-core';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import { EventEmitter } from 'node:events';
// Helper to create a mock SubAgentToolCallEvent with required fields
// Helper to create a mock AgentToolCallEvent with required fields
function createToolCallEvent(
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
): SubAgentToolCallEvent {
overrides: Partial<AgentToolCallEvent> & { name: string; callId: string },
): AgentToolCallEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -40,14 +40,14 @@ function createToolCallEvent(
};
}
// Helper to create a mock SubAgentToolResultEvent with required fields
// Helper to create a mock AgentToolResultEvent with required fields
function createToolResultEvent(
overrides: Partial<SubAgentToolResultEvent> & {
overrides: Partial<AgentToolResultEvent> & {
name: string;
callId: string;
success: boolean;
},
): SubAgentToolResultEvent {
): AgentToolResultEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -56,15 +56,15 @@ function createToolResultEvent(
};
}
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
// Helper to create a mock AgentApprovalRequestEvent with required fields
function createApprovalEvent(
overrides: Partial<SubAgentApprovalRequestEvent> & {
overrides: Partial<AgentApprovalRequestEvent> & {
name: string;
callId: string;
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
respond: SubAgentApprovalRequestEvent['respond'];
confirmationDetails: AgentApprovalRequestEvent['confirmationDetails'];
respond: AgentApprovalRequestEvent['respond'];
},
): SubAgentApprovalRequestEvent {
): AgentApprovalRequestEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -102,10 +102,10 @@ function createInfoConfirmation(
};
}
// Helper to create a mock SubAgentStreamTextEvent with required fields
// Helper to create a mock AgentStreamTextEvent with required fields
function createStreamTextEvent(
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
): SubAgentStreamTextEvent {
overrides: Partial<AgentStreamTextEvent> & { text: string },
): AgentStreamTextEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -120,7 +120,7 @@ describe('SubAgentTracker', () => {
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let requestPermissionSpy: ReturnType<typeof vi.fn>;
let tracker: SubAgentTracker;
let eventEmitter: SubAgentEventEmitter;
let eventEmitter: AgentEventEmitter;
let abortController: AbortController;
beforeEach(() => {
@ -151,7 +151,7 @@ describe('SubAgentTracker', () => {
'parent-call-123',
'test-subagent',
);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
eventEmitter = new EventEmitter() as unknown as AgentEventEmitter;
abortController = new AbortController();
});
@ -169,19 +169,19 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -193,19 +193,19 @@ describe('SubAgentTracker', () => {
cleanups[0]();
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -222,7 +222,7 @@ describe('SubAgentTracker', () => {
description: 'Reading file',
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Allow async operations to complete
await vi.waitFor(() => {
@ -258,7 +258,7 @@ describe('SubAgentTracker', () => {
args: { todos: [] },
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Give time for any async operation
await new Promise((resolve) => setTimeout(resolve, 10));
@ -276,7 +276,7 @@ describe('SubAgentTracker', () => {
args: {},
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -290,7 +290,7 @@ describe('SubAgentTracker', () => {
// First emit tool call to store state
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'read_file',
callId: 'call-123',
@ -306,7 +306,7 @@ describe('SubAgentTracker', () => {
resultDisplay: 'File contents',
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -334,7 +334,7 @@ describe('SubAgentTracker', () => {
resultDisplay: undefined,
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -356,7 +356,7 @@ describe('SubAgentTracker', () => {
// Store args via tool call
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
@ -377,7 +377,7 @@ describe('SubAgentTracker', () => {
}),
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
@ -393,7 +393,7 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -402,7 +402,7 @@ describe('SubAgentTracker', () => {
);
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -413,7 +413,7 @@ describe('SubAgentTracker', () => {
// Emit another result for same callId - should not have stored args
sendUpdateSpy.mockClear();
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -447,7 +447,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -483,7 +483,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
@ -504,7 +504,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -525,7 +525,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -548,7 +548,7 @@ describe('SubAgentTracker', () => {
respond: vi.fn(),
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -572,7 +572,7 @@ describe('SubAgentTracker', () => {
text: 'Hello, this is a response from the model.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -593,15 +593,15 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'First chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Second chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Third chunk' }),
);
@ -640,7 +640,7 @@ describe('SubAgentTracker', () => {
text: 'This should not be emitted',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -655,7 +655,7 @@ describe('SubAgentTracker', () => {
thought: true,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -680,7 +680,7 @@ describe('SubAgentTracker', () => {
thought: false,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -705,7 +705,7 @@ describe('SubAgentTracker', () => {
text: 'Default behavior text.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();

View file

@ -5,18 +5,18 @@
*/
import type {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentUsageEvent,
AgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
@ -106,12 +106,12 @@ export class SubAgentTracker {
/**
* Sets up event listeners for a sub-agent's tool events.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param eventEmitter - The AgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove listeners
*/
setup(
eventEmitter: SubAgentEventEmitter,
eventEmitter: AgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const onToolCall = this.createToolCallHandler(abortSignal);
@ -120,19 +120,19 @@ export class SubAgentTracker {
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);
eventEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(AgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
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);
eventEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(AgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@ -146,7 +146,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const event = args[0] as AgentToolCallEvent;
if (abortSignal.aborted) return;
// Look up tool and build invocation for metadata
@ -187,7 +187,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const event = args[0] as AgentToolResultEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -215,7 +215,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
const event = args[0] as AgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -292,7 +292,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
const event = args[0] as AgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(
@ -312,7 +312,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentStreamTextEvent;
const event = args[0] as AgentStreamTextEvent;
if (abortSignal.aborted) return;
// Emit streamed text as agent message or thought based on the flag
@ -330,6 +330,8 @@ export class SubAgentTracker {
private toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): PermissionOption[] {
const hideAlwaysAllow =
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
switch (confirmation.type) {
case 'edit':
return [
@ -342,34 +344,56 @@ export class SubAgentTracker {
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow',
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project',
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user',
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'plan':

View file

@ -5,6 +5,7 @@
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SubagentMeta } from '../types.js';
import type { Usage } from '@agentclientprotocol/sdk';
import { BaseEmitter } from './BaseEmitter.js';
@ -77,7 +78,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
subagentMeta?: import('../types.js').SubagentMeta,
subagentMeta?: SubagentMeta,
): Promise<void> {
const usage: Usage = {
inputTokens: usageMetadata.promptTokenCount ?? 0,

View file

@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, Argv } from 'yargs';
import {
handleQwenAuth,
runInteractiveAuth,
showAuthStatus,
} from './auth/handler.js';
import { t } from '../i18n/index.js';
// Define subcommands separately
const qwenOauthCommand = {
command: 'qwen-oauth',
describe: t('Authenticate using Qwen OAuth'),
handler: async () => {
await handleQwenAuth('qwen-oauth', {});
},
};
const codePlanCommand = {
command: 'coding-plan',
describe: t('Authenticate using Alibaba Cloud Coding Plan'),
builder: (yargs: Argv) =>
yargs
.option('region', {
alias: 'r',
describe: t('Region for Coding Plan (china/global)'),
type: 'string',
})
.option('key', {
alias: 'k',
describe: t('API key for Coding Plan'),
type: 'string',
}),
handler: async (argv: { region?: string; key?: string }) => {
const region = argv['region'] as string | undefined;
const key = argv['key'] as string | undefined;
// If region and key are provided, use them directly
if (region && key) {
await handleQwenAuth('coding-plan', { region, key });
} else {
// Otherwise, prompt interactively
await handleQwenAuth('coding-plan', {});
}
},
};
const statusCommand = {
command: 'status',
describe: t('Show current authentication status'),
handler: async () => {
await showAuthStatus();
},
};
export const authCommand: CommandModule = {
command: 'auth',
describe: t(
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
),
builder: (yargs: Argv) =>
yargs
.command(qwenOauthCommand)
.command(codePlanCommand)
.command(statusCommand)
.demandCommand(0) // Don't require a subcommand
.version(false),
handler: async () => {
// This handler is for when no subcommand is provided - show interactive menu
await runInteractiveAuth();
},
};

View file

@ -0,0 +1,500 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
getErrorMessage,
type Config,
type ProviderModelConfig as ModelConfig,
} from '@qwen-code/qwen-code-core';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { t } from '../../i18n/index.js';
import {
getCodingPlanConfig,
isCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { CliArgs } from '../../config/config.js';
import { InteractiveSelector } from './interactiveSelector.js';
interface QwenAuthOptions {
region?: string;
key?: string;
}
interface CodingPlanSettings {
region?: CodingPlanRegion;
version?: string;
}
interface MergedSettingsWithCodingPlan {
security?: {
auth?: {
selectedType?: string;
};
};
codingPlan?: CodingPlanSettings;
model?: {
name?: string;
};
modelProviders?: Record<string, ModelConfig[]>;
env?: Record<string, string>;
}
/**
* Handles the authentication process based on the specified command and options
*/
export async function handleQwenAuth(
command: 'qwen-oauth' | 'coding-plan',
options: QwenAuthOptions,
) {
try {
const settings = loadSettings();
// Create a minimal argv for config loading
const minimalArgv: CliArgs = {
query: undefined,
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
yolo: undefined,
approvalMode: undefined,
telemetry: undefined,
checkpointing: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
extensions: [],
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
chatRecording: undefined,
continue: undefined,
resume: undefined,
sessionId: undefined,
maxSessionTurns: undefined,
coreTools: undefined,
excludeTools: undefined,
authType: undefined,
channel: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
};
// Create a minimal config to access settings and storage
const config = await loadCliConfig(
settings.merged,
minimalArgv,
process.cwd(),
[], // No extensions for auth command
);
if (command === 'qwen-oauth') {
await handleQwenOAuth(config, settings);
} else if (command === 'coding-plan') {
await handleCodePlanAuth(config, settings, options);
}
// Exit after authentication is complete
writeStdoutLine(t('Authentication completed successfully.'));
process.exit(0);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
/**
* Handles Qwen OAuth authentication
*/
async function handleQwenOAuth(
config: Config,
settings: LoadedSettings,
): Promise<void> {
writeStdoutLine(t('Starting Qwen OAuth authentication...'));
try {
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Persist the auth type
const authTypeScope = getPersistScopeForModelSelection(settings);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
writeStdoutLine(t('Successfully authenticated with Qwen OAuth.'));
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Qwen OAuth: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Handles Alibaba Cloud Coding Plan authentication
*/
async function handleCodePlanAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<void> {
const { region, key } = options;
let selectedRegion: CodingPlanRegion;
let selectedKey: string;
// If region and key are provided as options, use them
if (region && key) {
selectedRegion =
region.toLowerCase() === 'global'
? CodingPlanRegion.GLOBAL
: CodingPlanRegion.CHINA;
selectedKey = key;
} else {
// Otherwise, prompt interactively
selectedRegion = await promptForRegion();
selectedKey = await promptForKey();
}
writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...'));
try {
// Get configuration based on region
const { template, version } = getCodingPlanConfig(selectedRegion);
// Get persist scope
const authTypeScope = getPersistScopeForModelSelection(settings);
// Backup settings file before modification
const settingsFile = settings.forScope(authTypeScope);
backupSettingsFile(settingsFile.path);
// Store api-key in settings.env (unified env key)
settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[CODING_PLAN_ENV_KEY] = selectedKey;
// Generate model configs from template
const newConfigs = template.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Get existing configs
const existingConfigs =
(settings.merged.modelProviders as Record<string, ModelConfig[]>)?.[
AuthType.USE_OPENAI
] || [];
// Filter out all existing Coding Plan configs (mutually exclusive)
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
authTypeScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan region
settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion);
// Persist coding plan version (single field for backward compatibility)
settings.setValue(authTypeScope, 'codingPlan.version', version);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(
authTypeScope,
'model.name',
(updatedConfigs[0] as ModelConfig).id,
);
}
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
writeStdoutLine(
t('Successfully authenticated with Alibaba Cloud Coding Plan.'),
);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Coding Plan: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Prompts the user to select a region using an interactive selector
*/
async function promptForRegion(): Promise<CodingPlanRegion> {
const selector = new InteractiveSelector(
[
{
value: CodingPlanRegion.CHINA,
label: t('中国 (China)'),
description: t('阿里云百炼 (aliyun.com)'),
},
{
value: CodingPlanRegion.GLOBAL,
label: t('Global'),
description: t('Alibaba Cloud (alibabacloud.com)'),
},
],
t('Select region for Coding Plan:'),
);
return await selector.select();
}
/**
* Prompts the user to enter an API key
*/
async function promptForKey(): Promise<string> {
// Create a simple password-style input (without echoing characters)
const stdin = process.stdin;
const stdout = process.stdout;
stdout.write(t('Enter your Coding Plan API key: '));
// Set raw mode to capture keystrokes
const wasRaw = stdin.isRaw;
if (stdin.setRawMode) {
stdin.setRawMode(true);
}
stdin.resume();
return new Promise<string>((resolve, reject) => {
let input = '';
const onData = (chunk: string) => {
for (const char of chunk) {
switch (char) {
case '\r': // Enter
case '\n':
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('\n'); // New line after input
resolve(input);
return;
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('^C\n');
reject(new Error('Interrupted'));
return;
case '\x08': // Backspace
case '\x7F': // Delete
if (input.length > 0) {
input = input.slice(0, -1);
// Move cursor back, print space, move back again
stdout.write('\x1B[D \x1B[D');
}
break;
default:
// Add character to input
input += char;
// Print asterisk instead of the actual character for security
stdout.write('*');
break;
}
}
};
stdin.on('data', onData);
});
}
/**
* Runs the interactive authentication flow
*/
export async function runInteractiveAuth() {
const selector = new InteractiveSelector(
[
{
value: 'qwen-oauth' as const,
label: t('Qwen OAuth'),
description: t('Free · Up to 1,000 requests/day · Qwen latest models'),
},
{
value: 'coding-plan' as const,
label: t('Alibaba Cloud Coding Plan'),
description: t(
'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models',
),
},
],
t('Select authentication method:'),
);
const choice = await selector.select();
if (choice === 'coding-plan') {
await handleQwenAuth('coding-plan', {});
} else {
await handleQwenAuth('qwen-oauth', {});
}
}
/**
* Shows the current authentication status
*/
export async function showAuthStatus(): Promise<void> {
try {
const settings = loadSettings();
const mergedSettings = settings.merged as MergedSettingsWithCodingPlan;
writeStdoutLine(t('\n=== Authentication Status ===\n'));
// Check for selected auth type
const selectedType = mergedSettings.security?.auth?.selectedType;
if (!selectedType) {
writeStdoutLine(t('⚠️ No authentication method configured.\n'));
writeStdoutLine(t('Run one of the following commands to get started:\n'));
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
),
);
writeStdoutLine(
t(
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
),
);
writeStdoutLine(t('Or simply run:'));
writeStdoutLine(
t(' qwen auth - Interactive authentication setup\n'),
);
process.exit(0);
}
// Display status based on auth type
if (selectedType === AuthType.QWEN_OAUTH) {
writeStdoutLine(t('✓ Authentication Method: Qwen OAuth'));
writeStdoutLine(t(' Type: Free tier'));
writeStdoutLine(t(' Limit: Up to 1,000 requests/day'));
writeStdoutLine(t(' Models: Qwen latest models\n'));
} else if (selectedType === AuthType.USE_OPENAI) {
// Check for Coding Plan configuration
const codingPlanRegion = mergedSettings.codingPlan?.region;
const codingPlanVersion = mergedSettings.codingPlan?.version;
const modelName = mergedSettings.model?.name;
// Check if API key is set in environment
const hasApiKey =
!!process.env[CODING_PLAN_ENV_KEY] ||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
);
if (codingPlanRegion) {
const regionDisplay =
codingPlanRegion === CodingPlanRegion.CHINA
? t('中国 (China) - 阿里云百炼')
: t('Global - Alibaba Cloud');
writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay }));
}
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
if (codingPlanVersion) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: codingPlanVersion.substring(0, 8) + '...',
}),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n'));
}
} else {
writeStdoutLine(
t('✓ Authentication Method: {{type}}', { type: selectedType }),
);
writeStdoutLine(t(' Status: Configured\n'));
}
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to check authentication status: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}

View file

@ -0,0 +1,421 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InteractiveSelector } from './interactiveSelector.js';
import { stdin, stdout } from 'node:process';
describe('InteractiveSelector', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1', description: 'First option' },
{ value: 'option2', label: 'Option 2', description: 'Second option' },
{ value: 'option3', label: 'Option 3', description: 'Third option' },
];
const mockPrompt = 'Select an option:';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create an instance with default prompt', () => {
const selector = new InteractiveSelector(mockOptions);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
it('should create an instance with custom prompt', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
});
describe('select', () => {
it('should reject if raw mode is not available', async () => {
// Mock stdin without setRawMode
const originalSetRawMode = stdin.setRawMode;
(stdin as any).setRawMode = undefined;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
await expect(selector.select()).rejects.toThrow(
'Raw mode not available. Please run in an interactive terminal.',
);
// Restore
(stdin as any).setRawMode = originalSetRawMode;
});
it('should select first option with Enter key', async () => {
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockSetEncoding = vi.fn();
const mockRemoveListener = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
// Simulate Enter key press
setTimeout(() => callback('\r'), 0);
return stdin;
});
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).setEncoding = mockSetEncoding;
(stdin as any).removeListener = mockRemoveListener;
(stdin as any).on = mockOn;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const result = await selector.select();
expect(result).toBe('option1');
expect(mockSetRawMode).toHaveBeenCalledWith(true);
expect(mockResume).toHaveBeenCalled();
stdoutWriteSpy.mockRestore();
});
it('should select second option after arrow down then Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate arrow down
dataCallback('\x1B[B');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should handle arrow up navigation', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down twice
dataCallback('\x1B[B');
dataCallback('\x1B[B');
// Move up once
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should reject with Ctrl+C', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate Ctrl+C
setTimeout(() => dataCallback('\x03'), 0);
await expect(selectPromise).rejects.toThrow('Interrupted');
});
it('should wrap around when navigating past last option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down past last option (should wrap to first)
dataCallback('\x1B[B');
dataCallback('\x1B[B');
dataCallback('\x1B[B'); // Now at option1 again (wrapped)
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should wrap around when navigating before first option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move up from first option (should wrap to last)
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option3');
stdoutWriteSpy.mockRestore();
});
it('should ignore arrow left/right keys', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Press arrow right (should be ignored)
dataCallback('\x1B[C');
// Press arrow left (should be ignored)
dataCallback('\x1B[D');
// Press Enter - should still select first option
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should handle newline character as Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate newline
setTimeout(() => dataCallback('\n'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
});
describe('renderMenu', () => {
it('should render menu with correct formatting', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).renderMenu();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('Select an option:');
expect(output).toContain('Option 1');
expect(output).toContain('Option 2');
expect(output).toContain('Option 3');
expect(output).toContain('First option');
expect(output).toContain('Second option');
expect(output).toContain('Third option');
expect(output).toContain('↑ ↓');
expect(output).toContain('Enter');
expect(output).toContain('Ctrl+C');
stdoutWriteSpy.mockRestore();
});
it('should highlight selected option', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
(selector as any).selectedIndex = 1;
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
// Selected option should have cyan color code
expect(output).toContain('\x1B[36m');
stdoutWriteSpy.mockRestore();
});
it('should calculate correct total lines', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).calculateTotalLines();
// Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7
expect((selector as any).calculateTotalLines()).toBe(7);
});
it('should handle options without descriptions', () => {
const simpleOptions = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
];
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(simpleOptions, mockPrompt);
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('A');
expect(output).toContain('B');
stdoutWriteSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { stdin, stdout } from 'node:process';
import { t } from '../../i18n/index.js';
/**
* Represents an option in the interactive selector
*/
interface Option<T> {
value: T;
label: string;
description?: string;
}
/**
* Interactive selector that allows users to navigate with arrow keys
*/
export class InteractiveSelector<T> {
private selectedIndex = 0;
private isListening = false;
constructor(
private options: Array<Option<T>>,
private prompt: string = t('Select an option:'),
) {}
/**
* Shows the interactive menu and waits for user selection
*/
async select(): Promise<T> {
return new Promise((resolve, reject) => {
this.isListening = true;
// Display initial menu
this.renderMenu();
// Check if stdin supports raw mode
if (!stdin.setRawMode) {
// Fallback to readline if raw mode is not available (e.g., when piped)
reject(
new Error(
t('Raw mode not available. Please run in an interactive terminal.'),
),
);
return;
}
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
const onData = (chunk: string) => {
if (!this.isListening) return;
for (const char of chunk) {
switch (char) {
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
reject(new Error('Interrupted'));
return;
case '\r': // Enter
case '\n': // Newline
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
resolve(this.options[this.selectedIndex].value);
return;
case '\x1B': // ESC sequence
// Next character will be [, then A, B, C, or D
break;
default:
// Handle other characters if needed
break;
}
}
// Handle escape sequences
if (chunk.startsWith('\x1B')) {
if (chunk === '\x1B[A') {
// Arrow up
this.moveUp();
} else if (chunk === '\x1B[B') {
// Arrow down
this.moveDown();
} else if (chunk === '\x1B[C') {
// Arrow right
// Do nothing for now
} else if (chunk === '\x1B[D') {
// Arrow left
// Do nothing for now
}
}
};
stdin.on('data', onData);
});
}
/**
* Renders the menu to stdout
*/
private renderMenu(): void {
// Calculate how many lines we need to clear
const totalLines = this.calculateTotalLines();
// Clear the screen area we'll be using
if (totalLines > 0) {
stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down
}
// Write the prompt
stdout.write(`${this.prompt}\n\n`);
// Write each option - combine label and description on same line
this.options.forEach((option, index) => {
const isSelected = index === this.selectedIndex;
const indicator = isSelected ? '> ' : ' ';
const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others
const reset = '\x1B[0m';
// Combine label and description in one line
let line = `${indicator}${color}${option.label}`;
if (option.description) {
line += ` - ${option.description}`;
}
line += `${reset}\n`;
stdout.write(line);
});
// Add instructions
stdout.write(
`\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`,
);
}
/**
* Calculates the total number of lines to clear
*/
private calculateTotalLines(): number {
// Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1)
return 4 + this.options.length;
}
/**
* Moves selection up
*/
private moveUp(): void {
this.selectedIndex =
(this.selectedIndex - 1 + this.options.length) % this.options.length;
this.renderMenu();
}
/**
* Moves selection down
*/
private moveDown(): void {
this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
this.renderMenu();
}
}

View file

@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { showAuthStatus } from './handler.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
describe('showAuthStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env[CODING_PLAN_ENV_KEY];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
const createMockSettings = (
merged: Record<string, unknown>,
): LoadedSettings =>
({
merged,
system: { settings: {}, path: '/system.json' },
systemDefaults: { settings: {}, path: '/system-defaults.json' },
user: { settings: {}, path: '/user.json' },
workspace: { settings: {}, path: '/workspace.json' },
forScope: vi.fn(),
setValue: vi.fn(),
isTrusted: true,
}) as unknown as LoadedSettings;
it('should show message when no authentication is configured', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('No authentication method configured'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth qwen-oauth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth coding-plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Qwen OAuth status when configured', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.QWEN_OAUTH,
},
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Qwen OAuth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Free tier'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('1,000 requests/day'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan status when configured with API key', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan as incomplete when API key is missing', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Incomplete'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
});
it('should show Coding Plan region for china', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('中国 (China)'),
);
});
it('should show Coding Plan region for global', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
model: {
name: 'qwen3-coder-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Global'),
);
});
it('should show current model name', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen3.5-plus'),
);
});
it('should show config version (truncated)', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456789',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('abc123de...'),
);
});
it('should handle errors and exit with code 1', async () => {
const error = new Error('Settings load failed');
vi.mocked(loadSettings).mockImplementation(() => {
throw error;
});
await showAuthStatus();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('Failed to check authentication status'),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
});

View file

@ -241,6 +241,30 @@ describe('parseArguments', () => {
expect(argv.prompt).toBeUndefined();
});
it('should parse --system-prompt', async () => {
process.argv = [
'node',
'script.js',
'--system-prompt',
'You are a test system prompt.',
];
const argv = await parseArguments();
expect(argv.systemPrompt).toBe('You are a test system prompt.');
expect(argv.appendSystemPrompt).toBeUndefined();
});
it('should parse --append-system-prompt', async () => {
process.argv = [
'node',
'script.js',
'--append-system-prompt',
'Be extra concise.',
];
const argv = await parseArguments();
expect(argv.appendSystemPrompt).toBe('Be extra concise.');
expect(argv.systemPrompt).toBeUndefined();
});
it('should allow -r flag as alias for --resume', async () => {
process.argv = [
'node',
@ -432,6 +456,21 @@ describe('parseArguments', () => {
mockExit.mockRestore();
});
it('should allow --system-prompt and --append-system-prompt together', async () => {
process.argv = [
'node',
'script.js',
'--system-prompt',
'Override prompt',
'--append-system-prompt',
'Append prompt',
];
const argv = await parseArguments();
expect(argv.systemPrompt).toBe('Override prompt');
expect(argv.appendSystemPrompt).toBe('Append prompt');
});
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
process.argv = ['node', 'script.js', '--include-partial-messages'];
@ -983,7 +1022,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual([]);
expect(config.getPermissionsDeny()).toEqual([]);
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
@ -992,7 +1031,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
expect(config.getPermissionsDeny()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => {
@ -1000,10 +1039,10 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments();
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual(
expect(config.getPermissionsDeny()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
expect(config.getPermissionsDeny()).toHaveLength(2);
});
});
@ -1028,7 +1067,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1047,7 +1086,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1067,7 +1106,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1084,7 +1123,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1101,7 +1140,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1121,7 +1160,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1141,7 +1180,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1154,7 +1193,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1179,7 +1218,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1199,7 +1238,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = { tools: { exclude: ['custom_tool'] } };
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain('custom_tool'); // From settings
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
@ -1795,9 +1834,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
@ -1805,9 +1844,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
@ -1815,9 +1854,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).toContain('run_shell_command');
expect(config.getExcludeTools()).toContain('edit');
expect(config.getExcludeTools()).toContain('write_file');
expect(config.getPermissionsDeny()).toContain('run_shell_command');
expect(config.getPermissionsDeny()).toContain('edit');
expect(config.getPermissionsDeny()).toContain('write_file');
});
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
@ -1825,9 +1864,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
});

View file

@ -10,7 +10,6 @@ import {
Config,
DEFAULT_QWEN_EMBEDDING_MODEL,
FileDiscoveryService,
FileEncoding,
getAllGeminiMdFilenames,
loadServerHierarchicalMemory,
setGeminiMdFilename as setServerGeminiMdFilename,
@ -19,7 +18,6 @@ import {
Storage,
InputFormat,
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
@ -31,10 +29,13 @@ import {
NativeLspClient,
createDebugLogger,
NativeLspService,
isToolEnabled,
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import type { Settings } from './settings.js';
import type { Settings, LoadedSettings } from './settings.js';
import { SettingScope } from './settings.js';
import { authCommand } from '../commands/auth.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
@ -52,16 +53,16 @@ import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
// UUID v4 regex pattern for validation
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SESSION_ID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i;
/**
* Validates if a string is a valid UUID format
* @param value - The string to validate
* @returns True if the string is a valid UUID, false otherwise
* Validates if a string is a valid session ID format.
* Accepts a standard UUID, or a UUID followed by `-agent-{suffix}`
* (used by Arena to give each agent a deterministic session ID).
*/
function isValidUUID(value: string): boolean {
return UUID_REGEX.test(value);
function isValidSessionId(value: string): boolean {
return SESSION_ID_REGEX.test(value);
}
import { isWorkspaceTrusted } from './trustedFolders.js';
@ -111,6 +112,8 @@ export interface CliArgs {
debug: boolean | undefined;
prompt: string | undefined;
promptInteractive: string | undefined;
systemPrompt: string | undefined;
appendSystemPrompt: string | undefined;
yolo: boolean | undefined;
approvalMode: string | undefined;
telemetry: boolean | undefined;
@ -290,6 +293,16 @@ export async function parseArguments(): Promise<CliArgs> {
description:
'Execute the provided prompt and continue in interactive mode',
})
.option('system-prompt', {
type: 'string',
description:
'Override the main session system prompt for this run. Can be combined with --append-system-prompt.',
})
.option('append-system-prompt', {
type: 'string',
description:
'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.',
})
.option('sandbox', {
alias: 's',
type: 'boolean',
@ -386,6 +399,7 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'List all available extensions and exit.',
})
.option('include-directories', {
alias: 'add-dir',
type: 'array',
string: true,
description:
@ -557,10 +571,13 @@ export async function parseArguments(): Promise<CliArgs> {
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
}
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
if (
argv['sessionId'] &&
!isValidSessionId(argv['sessionId'] as string)
) {
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) {
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
return true;
@ -570,6 +587,8 @@ export async function parseArguments(): Promise<CliArgs> {
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand)
// Register Auth subcommands
.command(authCommand)
// Register Hooks subcommands
.command(hooksCommand);
@ -685,6 +704,7 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const debugMode = isDebugMode(argv);
@ -814,64 +834,106 @@ export async function loadCliConfig(
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt.
// However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case.
const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
// ── Unified permissions construction ─────────────────────────────────────
// All permission sources are merged here, before constructing Config.
// The resulting three arrays are the single source of truth that Config /
// PermissionManager will use.
//
// Sources (in order of precedence within each list):
// 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings)
// 2. argv.coreTools → allow (allowlist mode: only these tools are available)
// 3. argv.allowedTools → allow (auto-approve these tools/commands)
// 4. argv.excludeTools → deny (block these tools completely)
// 5. Non-interactive mode exclusions → deny (unless explicitly allowed above)
// Start from settings-level rules.
// Read from both new `permissions` and legacy `tools` paths for compatibility.
// Note: settings.tools.core / argv.coreTools are intentionally NOT merged into
// mergedAllow — they have whitelist semantics (only listed tools are registered),
// not auto-approve semantics. They are passed via the `coreTools` Config param
// and handled by PermissionManager.coreToolsAllowList.
const resolvedCoreTools: string[] = [
...(argv.coreTools ?? []),
...(settings.tools?.core ?? []),
];
const mergedAllow: string[] = [
...(settings.permissions?.allow ?? []),
...(settings.tools?.allowed ?? []),
];
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
const mergedDeny: string[] = [
...(settings.permissions?.deny ?? []),
...(settings.tools?.exclude ?? []),
];
// argv.allowedTools adds allow rules (auto-approve).
for (const t of argv.allowedTools ?? []) {
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
}
// argv.excludeTools adds deny rules.
for (const t of argv.excludeTools ?? []) {
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
}
// Helper: check if a tool is explicitly covered by an allow rule OR by the
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
// to preserve the original behaviour where "ShellTool", "Shell", and
// "run_shell_command" are all accepted as the same tool.
const isExplicitlyAllowed = (toolName: ToolName): boolean => {
const name = toolName as string;
// 1. Check permissions.allow / allowedTools rules.
if (
mergedAllow.some((rule) => {
const openParen = rule.indexOf('(');
const ruleName =
openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim();
return ruleName === name;
})
) {
return true;
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
// 2. Check coreTools whitelist (with alias matching).
// If coreTools is non-empty and explicitly includes this tool, it is
// considered allowed for non-interactive mode exclusion purposes.
if (resolvedCoreTools.length > 0) {
return isToolEnabled(toolName, resolvedCoreTools, []);
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
// In non-interactive mode, tools that require a user prompt are denied unless
// the caller has explicitly allowed them. Stream-JSON input is excluded from
// this logic because approval can be sent programmatically via JSON messages.
const isAcpMode = argv.acp || argv.experimentalAcp;
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
const denyUnlessAllowed = (toolName: ToolName): void => {
if (!isExplicitlyAllowed(toolName)) {
const name = toolName as string;
if (!mergedDeny.includes(name)) mergedDeny.push(name);
}
};
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded,
// unless explicitly enabled via coreTools/allowedTools.
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
// Deny all write/execute tools unless explicitly allowed.
denyUnlessAllowed(ShellTool.Name as ToolName);
denyUnlessAllowed(EditTool.Name as ToolName);
denyUnlessAllowed(WriteFileTool.Name as ToolName);
break;
case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
excludeUnlessExplicit(ShellTool.Name as ToolName);
// Only shell requires a prompt in auto-edit mode.
denyUnlessAllowed(ShellTool.Name as ToolName);
break;
case ApprovalMode.YOLO:
// No extra excludes for YOLO mode.
// No extra denials for YOLO mode.
break;
default:
// This should never happen due to validation earlier, but satisfies the linter
break;
}
}
const excludeTools = mergeExcludeTools(
settings,
extraExcludes.length > 0 ? extraExcludes : undefined,
argv.excludeTools,
);
let allowedMcpServers: Set<string> | undefined;
let excludedMcpServers: Set<string> | undefined;
if (argv.allowedMcpServerNames) {
@ -962,9 +1024,33 @@ export async function loadCliConfig(
importFormat: settings.context?.importFormat || 'tree',
debugMode,
question,
systemPrompt: argv.systemPrompt,
appendSystemPrompt: argv.appendSystemPrompt,
// Legacy fields kept for backward compatibility with getCoreTools() etc.
coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools,
excludeTools: mergedDeny,
// New unified permissions (PermissionManager source of truth).
permissions: {
allow: mergedAllow.length > 0 ? mergedAllow : undefined,
ask: mergedAsk.length > 0 ? mergedAsk : undefined,
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
},
// Permission rule persistence callback (writes to settings files).
onPersistPermissionRule: loadedSettings
? async (scope, ruleType, rule) => {
const settingScope =
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
const key = `permissions.${ruleType}`;
const currentRules: string[] =
loadedSettings.forScope(settingScope).settings.permissions?.[
ruleType
] ?? [];
if (!currentRules.includes(rule)) {
loadedSettings.setValue(settingScope, key, [...currentRules, rule]);
}
}
: undefined,
toolDiscoveryCommand: settings.tools?.discoveryCommand,
toolCallCommand: settings.tools?.callCommand,
mcpServerCommand: settings.mcp?.serverCommand,
@ -1013,7 +1099,6 @@ export async function loadCliConfig(
warnings: resolvedCliConfig.warnings,
cliVersion: await getCliVersion(),
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
summarizeToolOutput: settings.model?.summarizeToolOutput,
ideMode,
chatCompression: settings.model?.chatCompression,
folderTrust,
@ -1027,7 +1112,6 @@ export async function loadCliConfig(
skipStartupContext: settings.model?.skipStartupContext ?? false,
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
eventEmitter: appEvents,
gitCoAuthor: settings.general?.gitCoAuthor,
output: {
@ -1043,11 +1127,22 @@ export async function loadCliConfig(
// always be true and the settings file can never disable recording.
chatRecording:
argv.chatRecording ?? settings.general?.chatRecording ?? true,
defaultFileEncoding:
settings.general?.defaultFileEncoding ?? FileEncoding.UTF8,
defaultFileEncoding: settings.general?.defaultFileEncoding,
lsp: {
enabled: lspEnabled,
},
agents: settings.agents
? {
displayMode: settings.agents.displayMode,
arena: settings.agents.arena
? {
worktreeBaseDir: settings.agents.arena.worktreeBaseDir,
preserveArtifacts:
settings.agents.arena.preserveArtifacts ?? false,
}
: undefined,
}
: undefined,
});
if (lspEnabled) {
@ -1074,16 +1169,3 @@ export async function loadCliConfig(
return config;
}
function mergeExcludeTools(
settings: Settings,
extraExcludes?: string[] | undefined,
cliExcludeTools?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(cliExcludeTools || []),
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
return [...allExcludeTools];
}

View file

@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
shellPager: 'tools.shell.pager',
shellShowColor: 'tools.shell.showColor',
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
summarizeToolOutput: 'model.summarizeToolOutput',
telemetry: 'telemetry',
theme: 'ui.theme',
toolDiscoveryCommand: 'tools.discoveryCommand',
@ -157,7 +156,6 @@ export const V1_INDICATOR_KEYS = [
'shellPager',
'shellShowColor',
'skipNextSpeakerCheck',
'summarizeToolOutput',
'toolDiscoveryCommand',
'toolCallCommand',
'usageStatisticsEnabled',

View file

@ -67,6 +67,74 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
export const SETTINGS_VERSION = 3;
export const SETTINGS_VERSION_KEY = '$version';
/**
* Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude)
* to the new permissions.allow / permissions.ask / permissions.deny format.
*
* Conversion rules:
* tools.allowed permissions.allow (bypass confirmation)
* tools.exclude permissions.deny (block tools)
* tools.core permissions.allow (only listed tools enabled)
* + permissions.deny with a wildcard deny-all if needed
*
* Returns the updated settings object, or null if no migration is needed.
*/
export function migrateLegacyPermissions(
settings: Record<string, unknown>,
): Record<string, unknown> | null {
const tools = settings['tools'] as Record<string, unknown> | undefined;
if (!tools) return null;
const hasLegacy =
Array.isArray(tools['core']) ||
Array.isArray(tools['allowed']) ||
Array.isArray(tools['exclude']);
if (!hasLegacy) return null;
const result = structuredClone(settings) as Record<string, unknown>;
const resultTools = result['tools'] as Record<string, unknown>;
const permissions = (result['permissions'] as Record<string, unknown>) ?? {};
result['permissions'] = permissions;
const mergeInto = (key: string, items: string[]) => {
const existing = Array.isArray(permissions[key])
? (permissions[key] as string[])
: [];
const merged = Array.from(new Set([...existing, ...items]));
permissions[key] = merged;
};
// tools.allowed → permissions.allow
if (Array.isArray(resultTools['allowed'])) {
mergeInto('allow', resultTools['allowed'] as string[]);
delete resultTools['allowed'];
}
// tools.exclude → permissions.deny
if (Array.isArray(resultTools['exclude'])) {
mergeInto('deny', resultTools['exclude'] as string[]);
delete resultTools['exclude'];
}
// tools.core → permissions.allow (explicit enables)
// IMPORTANT: tools.core has whitelist semantics: "only these tools can run".
// To preserve this, we also add deny rules for all tools NOT in the list.
// A wildcard deny-all followed by specific allows achieves this because
// allow rules take precedence over the catch-all deny in the evaluation order:
// deny = [everything not listed], allow = [listed tools]
// However, since our priority is deny > allow, we cannot use a blanket deny.
// Instead we just migrate to allow (auto-approve) and let the coreTools
// semantics continue to work through the Config.getCoreTools() path until
// the old API is fully removed.
if (Array.isArray(resultTools['core'])) {
mergeInto('allow', resultTools['core'] as string[]);
delete resultTools['core'];
}
return result;
}
export function getSystemSettingsPath(): string {
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
@ -103,10 +171,6 @@ export interface CheckpointingSettings {
enabled?: boolean;
}
export interface SummarizeToolOutputSettings {
tokenBudget?: number;
}
export interface AccessibilitySettings {
enableLoadingPhrases?: boolean;
screenReader?: boolean;

View file

@ -181,9 +181,7 @@ describe('SettingsSchema', () => {
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
false,
);
expect(getSettingsSchema().tools.properties.core.showInDialog).toBe(
false,
);
expect(getSettingsSchema().permissions.showInDialog).toBe(false);
expect(getSettingsSchema().mcpServers.showInDialog).toBe(false);
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);

View file

@ -76,12 +76,98 @@ export interface SettingDefinition {
mergeStrategy?: MergeStrategy;
/** Enum type options */
options?: readonly SettingEnumOption[];
/** Schema for array items when type is 'array' */
items?: SettingItemDefinition;
}
/**
* Schema definition for array item types.
* Supports simple types (string, number, boolean) and complex object types.
*/
export interface SettingItemDefinition {
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
properties?: Record<
string,
SettingItemDefinition & {
required?: boolean;
enum?: string[];
additionalProperties?: SettingItemDefinition;
}
>;
items?: SettingItemDefinition;
required?: boolean;
enum?: string[];
description?: string;
additionalProperties?: boolean | SettingItemDefinition;
}
export interface SettingsSchema {
[key: string]: SettingDefinition;
}
/**
* Common items schema for hook definitions.
* Used by both UserPromptSubmit and Stop hooks.
*/
const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
type: 'object',
description:
'A hook definition with an optional matcher and a list of hook configurations.',
properties: {
matcher: {
type: 'string',
description:
'An optional matcher pattern to filter when this hook definition applies.',
},
sequential: {
type: 'boolean',
description:
'Whether the hooks should be executed sequentially instead of in parallel.',
},
hooks: {
type: 'array',
description: 'The list of hook configurations to execute.',
required: true,
items: {
type: 'object',
description:
'A hook configuration entry that defines a command to execute.',
properties: {
type: {
type: 'string',
description: 'The type of hook.',
enum: ['command'],
required: true,
},
command: {
type: 'string',
description: 'The command to execute when the hook is triggered.',
required: true,
},
name: {
type: 'string',
description: 'An optional name for the hook.',
},
description: {
type: 'string',
description: 'An optional description of what the hook does.',
},
timeout: {
type: 'number',
description: 'Timeout in milliseconds for the hook execution.',
},
env: {
type: 'object',
description:
'Environment variables to set when executing the hook command.',
additionalProperties: { type: 'string' },
},
},
},
},
},
};
export type MemoryImportFormat = 'tree' | 'flat';
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
@ -546,17 +632,6 @@ const SETTINGS_SCHEMA = {
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
showInDialog: false,
},
summarizeToolOutput: {
type: 'object',
label: 'Summarize Tool Output',
category: 'Model',
requiresRestart: false,
default: undefined as
| Record<string, { tokenBudget?: number }>
| undefined,
description: 'Settings for summarizing tool output.',
showInDialog: false,
},
chatCompression: {
type: 'object',
label: 'Chat Compression',
@ -789,6 +864,55 @@ const SETTINGS_SCHEMA = {
},
},
permissions: {
type: 'object',
label: 'Permissions',
category: 'Tools',
requiresRestart: true,
default: {},
description:
'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.',
showInDialog: false,
properties: {
allow: {
type: 'array',
label: 'Allow Rules',
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Tools or commands that are auto-approved without confirmation. ' +
'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
ask: {
type: 'array',
label: 'Ask Rules',
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Tools or commands that always require user confirmation. ' +
'Takes precedence over allow rules.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
deny: {
type: 'array',
label: 'Deny Rules',
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Tools or commands that are always blocked. Highest priority rule. ' +
'Examples: "ShellTool", "Bash(rm -rf *)".',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},
tools: {
type: 'object',
label: 'Tools',
@ -848,32 +972,33 @@ const SETTINGS_SCHEMA = {
},
},
},
// Legacy tool permission fields kept for backward compatibility.
// Use permissions.{allow,ask,deny} instead.
core: {
type: 'array',
label: 'Core Tools',
label: 'Core Tools (deprecated)',
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Paths to core tool definitions.',
description: 'Deprecated. Use permissions.allow instead.',
showInDialog: false,
},
allowed: {
type: 'array',
label: 'Allowed Tools',
label: 'Allowed Tools (deprecated)',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'A list of tool names that will bypass the confirmation dialog.',
description: 'Deprecated. Use permissions.allow instead.',
showInDialog: false,
},
exclude: {
type: 'array',
label: 'Exclude Tools',
label: 'Exclude Tools (deprecated)',
category: 'Tools',
requiresRestart: true,
default: undefined as string[] | undefined,
description: 'Tool names to exclude from discovery.',
description: 'Deprecated. Use permissions.deny instead.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
@ -941,15 +1066,6 @@ const SETTINGS_SCHEMA = {
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
showInDialog: false,
},
enableToolOutputTruncation: {
type: 'boolean',
label: 'Enable Tool Output Truncation',
category: 'General',
requiresRestart: true,
default: true,
description: 'Enable truncation of large tool outputs.',
showInDialog: false,
},
truncateToolOutputThreshold: {
type: 'number',
label: 'Tool Output Truncation Threshold',
@ -1178,6 +1294,104 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for web search providers.',
showInDialog: false,
},
agents: {
type: 'object',
label: 'Agents',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for multi-agent collaboration features (Arena, Team, Swarm).',
showInDialog: false,
properties: {
displayMode: {
type: 'enum',
label: 'Display Mode',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
showInDialog: false,
options: [
{ value: 'in-process', label: 'In-process' },
// { value: 'tmux', label: 'tmux' },
// { value: 'iterm2', label: 'iTerm2' },
],
},
arena: {
type: 'object',
label: 'Arena',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Settings for Arena (multi-model competitive execution).',
showInDialog: false,
properties: {
worktreeBaseDir: {
type: 'string',
label: 'Worktree Base Directory',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.',
showInDialog: false,
},
preserveArtifacts: {
type: 'boolean',
label: 'Preserve Arena Artifacts',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.',
showInDialog: true,
},
maxRoundsPerAgent: {
type: 'number',
label: 'Max Rounds Per Agent',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum number of rounds (turns) each agent can execute. No limit if unset.',
showInDialog: false,
},
timeoutSeconds: {
type: 'number',
label: 'Timeout (seconds)',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Total timeout in seconds for the Arena session. No limit if unset.',
showInDialog: false,
},
},
},
team: {
type: 'object',
label: 'Team',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Team (role-based collaborative execution). Reserved for future use.',
showInDialog: false,
},
swarm: {
type: 'object',
label: 'Swarm',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.',
showInDialog: false,
},
},
},
hooksConfig: {
type: 'object',
@ -1233,6 +1447,7 @@ const SETTINGS_SCHEMA = {
'Hooks that execute before agent processing. Can modify prompts or inject context.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
Stop: {
type: 'array',
@ -1244,9 +1459,124 @@ const SETTINGS_SCHEMA = {
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
Notification: {
type: 'array',
label: 'Notification Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when notifications are sent.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreToolUse: {
type: 'array',
label: 'Pre Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUse: {
type: 'array',
label: 'Post Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute after successful tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUseFailure: {
type: 'array',
label: 'Post Tool Use Failure Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when tool execution fails. ',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionStart: {
type: 'array',
label: 'Session Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a new session starts or resumes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionEnd: {
type: 'array',
label: 'Session End Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a session ends.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreCompact: {
type: 'array',
label: 'Pre Compact Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before conversation compaction.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStart: {
type: 'array',
label: 'Subagent Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a subagent (Task tool call) is started.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStop: {
type: 'array',
label: 'Subagent Stop Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PermissionRequest: {
type: 'array',
label: 'Permission Request Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a permission dialog is displayed.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},
experimental: {
type: 'object',
label: 'Experimental',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;

View file

@ -97,7 +97,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{
@ -222,7 +222,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{

View file

@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
query: undefined,
yolo: undefined,
approvalMode: undefined,

View file

@ -35,6 +35,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { SessionStatsProvider } from './ui/contexts/SessionContext.js';
import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
@ -162,13 +163,15 @@ export async function startInteractiveUI(
>
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
<AgentViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</AgentViewProvider>
</VimModeProvider>
</SessionStatsProvider>
</KeypressProvider>
@ -348,6 +351,7 @@ export async function main() {
argv,
process.cwd(),
argv.extensions,
settings,
);
// Register cleanup for MCP clients as early as possible

View file

@ -99,6 +99,7 @@ export default {
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
'List available Qwen Code tools. Usage: /tools [desc]':
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
'List available skills.': 'Verfügbare Skills auflisten.',
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
'No tools available': 'Keine Werkzeuge verfügbar',
'View or change the approval mode for tool usage':
@ -376,6 +377,7 @@ export default {
'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.',
'Your preferred editor is:': 'Ihr bevorzugter Editor ist:',
'Manage extensions': 'Erweiterungen verwalten',
'Manage installed extensions': 'Installierte Erweiterungen verwalten',
'List active extensions': 'Aktive Erweiterungen auflisten',
'Update extensions. Usage: update <extension-names>|--all':
'Erweiterungen aktualisieren. Verwendung: update <Erweiterungsnamen>|--all',
@ -585,6 +587,38 @@ export default {
'Fehler beim Konfigurieren von {{terminalName}}.',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': 'Qwen Code-Hooks verwalten',
'List all configured hooks': 'Alle konfigurierten Hooks auflisten',
'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren',
'Disable an active hook': 'Einen aktiven Hook deaktivieren',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'Den Nachrichtenverlauf der aktuellen Sitzung in eine Datei exportieren',
'Export session to HTML format': 'Sitzung in das HTML-Format exportieren',
'Export session to JSON format': 'Sitzung in das JSON-Format exportieren',
'Export session to JSONL format (one message per line)':
'Sitzung in das JSONL-Format exportieren (eine Nachricht pro Zeile)',
'Export session to markdown format':
'Sitzung in das Markdown-Format exportieren',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'Personalisierte Programmier-Einblicke aus Ihrem Chatverlauf generieren',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': 'Eine vorherige Sitzung fortsetzen',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'Einen Tool-Aufruf wiederherstellen. Dadurch werden Konversations- und Dateiverlauf auf den Zustand zurückgesetzt, in dem der Tool-Aufruf vorgeschlagen wurde',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.',
'Terminal "{{terminal}}" is not supported yet.':
@ -1012,6 +1046,8 @@ export default {
"Allow execution of: '{{command}}'?":
"Ausführung erlauben von: '{{command}}'?",
'Yes, allow always ...': 'Ja, immer erlauben ...',
'Always allow in this project': 'In diesem Projekt immer erlauben',
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',
@ -1180,6 +1216,75 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': 'Ordnervertrauenseinstellungen verwalten',
'Manage permission rules': 'Berechtigungsregeln verwalten',
Allow: 'Erlauben',
Ask: 'Fragen',
Deny: 'Verweigern',
Workspace: 'Arbeitsbereich',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code fragt nicht, bevor erlaubte Tools verwendet werden.',
'Qwen Code will ask before using these tools.':
'Qwen Code fragt, bevor diese Tools verwendet werden.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code darf verweigerte Tools nicht verwenden.',
'Manage trusted directories for this workspace.':
'Vertrauenswürdige Verzeichnisse für diesen Arbeitsbereich verwalten.',
'Any use of the {{tool}} tool': 'Jede Verwendung des {{tool}}-Tools',
"{{tool}} commands matching '{{pattern}}'":
"{{tool}}-Befehle, die '{{pattern}}' entsprechen",
'From user settings': 'Aus Benutzereinstellungen',
'From project settings': 'Aus Projekteinstellungen',
'From session': 'Aus Sitzung',
'Project settings (local)': 'Projekteinstellungen (lokal)',
'Saved in .qwen/settings.local.json':
'Gespeichert in .qwen/settings.local.json',
'Project settings': 'Projekteinstellungen',
'Checked in at .qwen/settings.json': 'Eingecheckt in .qwen/settings.json',
'User settings': 'Benutzereinstellungen',
'Saved in at ~/.qwen/settings.json': 'Gespeichert in ~/.qwen/settings.json',
'Add a new rule…': 'Neue Regel hinzufügen…',
'Add {{type}} permission rule': '{{type}}-Berechtigungsregel hinzufügen',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Berechtigungsregeln sind ein Toolname, optional gefolgt von einem Bezeichner in Klammern.',
'e.g.,': 'z.B.',
or: 'oder',
'Enter permission rule…': 'Berechtigungsregel eingeben…',
'Enter to submit · Esc to cancel': 'Enter zum Absenden · Esc zum Abbrechen',
'Where should this rule be saved?': 'Wo soll diese Regel gespeichert werden?',
'Enter to confirm · Esc to cancel':
'Enter zum Bestätigen · Esc zum Abbrechen',
'Delete {{type}} rule?': '{{type}}-Regel löschen?',
'Are you sure you want to delete this permission rule?':
'Sind Sie sicher, dass Sie diese Berechtigungsregel löschen möchten?',
'Permissions:': 'Berechtigungen:',
'(←/→ or tab to cycle)': '(←/→ oder Tab zum Wechseln)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ navigieren · Enter auswählen · Tippen suchen · Esc abbrechen',
'Search…': 'Suche…',
'Use /trust to manage folder trust settings for this workspace.':
'Verwenden Sie /trust, um die Ordnervertrauenseinstellungen für diesen Arbeitsbereich zu verwalten.',
// Workspace directory management
'Add directory…': 'Verzeichnis hinzufügen…',
'Add directory to workspace': 'Verzeichnis zum Arbeitsbereich hinzufügen',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code kann Dateien im Arbeitsbereich lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code kann Dateien in diesem Verzeichnis lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
'Enter the path to the directory:': 'Pfad zum Verzeichnis eingeben:',
'Enter directory path…': 'Verzeichnispfad eingeben…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab zum Vervollständigen · Enter zum Hinzufügen · Esc zum Abbrechen',
'Remove directory?': 'Verzeichnis entfernen?',
'Are you sure you want to remove this directory from the workspace?':
'Möchten Sie dieses Verzeichnis wirklich aus dem Arbeitsbereich entfernen?',
' (Original working directory)': ' (Ursprüngliches Arbeitsverzeichnis)',
' (from settings)': ' (aus Einstellungen)',
'Directory does not exist.': 'Verzeichnis existiert nicht.',
'Path is not a directory.': 'Pfad ist kein Verzeichnis.',
'This directory is already in the workspace.':
'Dieses Verzeichnis ist bereits im Arbeitsbereich.',
'Already covered by existing directory: {{dir}}':
'Bereits durch vorhandenes Verzeichnis abgedeckt: {{dir}}',
// ============================================================================
// Status Bar
@ -1586,6 +1691,36 @@ export default {
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Kontextnutzung',
'No API response yet. Send a message to see actual usage.':
'Noch keine API-Antwort. Senden Sie eine Nachricht, um die tatsächliche Nutzung anzuzeigen.',
'Estimated pre-conversation overhead':
'Geschätzte Vorabkosten vor der Unterhaltung',
'Context window': 'Kontextfenster',
tokens: 'Tokens',
Used: 'Verwendet',
Free: 'Frei',
'Autocompact buffer': 'Autokomprimierungs-Puffer',
'Usage by category': 'Verwendung nach Kategorie',
'System prompt': 'System-Prompt',
'Built-in tools': 'Integrierte Tools',
'MCP tools': 'MCP-Tools',
'Memory files': 'Speicherdateien',
Skills: 'Fähigkeiten',
Messages: 'Nachrichten',
'Show context window usage breakdown.':
'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.',
'Run /context detail for per-item breakdown.':
'Führen Sie /context detail für eine Aufschlüsselung nach Elementen aus.',
active: 'aktiv',
'body loaded': 'Inhalt geladen',
memory: 'Speicher',
'{{region}} configuration updated successfully.':
'{{region}}-Konfiguration erfolgreich aktualisiert.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1621,4 +1756,80 @@ export default {
'↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-Authentifizierung mit Qwen-OAuth oder Alibaba Cloud Coding Plan konfigurieren',
'Authenticate using Qwen OAuth': 'Mit Qwen OAuth authentifizieren',
'Authenticate using Alibaba Cloud Coding Plan':
'Mit Alibaba Cloud Coding Plan authentifizieren',
'Region for Coding Plan (china/global)':
'Region für Coding Plan (china/global)',
'API key for Coding Plan': 'API-Schlüssel für Coding Plan',
'Show current authentication status':
'Aktuellen Authentifizierungsstatus anzeigen',
'Authentication completed successfully.':
'Authentifizierung erfolgreich abgeschlossen.',
'Starting Qwen OAuth authentication...':
'Qwen OAuth-Authentifizierung wird gestartet...',
'Successfully authenticated with Qwen OAuth.':
'Erfolgreich mit Qwen OAuth authentifiziert.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Authentifizierung mit Qwen OAuth fehlgeschlagen: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan-Authentifizierung wird verarbeitet...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Erfolgreich mit Alibaba Cloud Coding Plan authentifiziert.',
'Failed to authenticate with Coding Plan: {{error}}':
'Authentifizierung mit Coding Plan fehlgeschlagen: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Region für Coding Plan auswählen:',
'Enter your Coding Plan API key: ':
'Geben Sie Ihren Coding Plan API-Schlüssel ein: ',
'Select authentication method:': 'Authentifizierungsmethode auswählen:',
'\n=== Authentication Status ===\n': '\n=== Authentifizierungsstatus ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Keine Authentifizierungsmethode konfiguriert.\n',
'Run one of the following commands to get started:\n':
'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Mit Qwen OAuth authentifizieren (kostenlos)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n',
'Or simply run:': 'Oder einfach ausführen:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interaktive Authentifizierungseinrichtung\n',
'✓ Authentication Method: Qwen OAuth':
'✓ Authentifizierungsmethode: Qwen OAuth',
' Type: Free tier': ' Typ: Kostenlos',
' Limit: Up to 1,000 requests/day': ' Limit: Bis zu 1.000 Anfragen/Tag',
' Models: Qwen latest models\n': ' Modelle: Qwen neueste Modelle\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentifizierungsmethode: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Aktuelles Modell: {{model}}',
' Config Version: {{version}}': ' Konfigurationsversion: {{version}}',
' Status: API key configured\n': ' Status: API-Schlüssel konfiguriert\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)',
' Issue: API key not found in environment or settings\n':
' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Führen Sie `qwen auth coding-plan` aus, um neu zu konfigurieren.\n',
'✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}',
' Status: Configured\n': ' Status: Konfiguriert\n',
'Failed to check authentication status: {{error}}':
'Authentifizierungsstatus konnte nicht überprüft werden: {{error}}',
'Select an option:': 'Option auswählen:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n',
};

View file

@ -118,6 +118,7 @@ export default {
'Analyzes the project and creates a tailored QWEN.md file.',
'List available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]',
'List available skills.': 'List available skills.',
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
'No tools available': 'No tools available',
'View or change the approval mode for tool usage':
@ -459,6 +460,7 @@ export default {
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
'Your preferred editor is:': 'Your preferred editor is:',
'Manage extensions': 'Manage extensions',
'Manage installed extensions': 'Manage installed extensions',
'List active extensions': 'List active extensions',
'Update extensions. Usage: update <extension-names>|--all':
'Update extensions. Usage: update <extension-names>|--all',
@ -659,6 +661,37 @@ export default {
'Failed to configure {{terminalName}}.',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': 'Manage Qwen Code hooks',
'List all configured hooks': 'List all configured hooks',
'Enable a disabled hook': 'Enable a disabled hook',
'Disable an active hook': 'Disable an active hook',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'Export current session message history to a file',
'Export session to HTML format': 'Export session to HTML format',
'Export session to JSON format': 'Export session to JSON format',
'Export session to JSONL format (one message per line)':
'Export session to JSONL format (one message per line)',
'Export session to markdown format': 'Export session to markdown format',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'generate personalized programming insights from your chat history',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': 'Resume a previous session',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.',
'Terminal "{{terminal}}" is not supported yet.':
@ -1069,6 +1102,8 @@ export default {
'No, suggest changes (esc)': 'No, suggest changes (esc)',
"Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?",
'Yes, allow always ...': 'Yes, allow always ...',
'Always allow in this project': 'Always allow in this project',
'Always allow for this user': 'Always allow for this user',
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
'Yes, and manually approve edits': 'Yes, and manually approve edits',
'No, keep planning (esc)': 'No, keep planning (esc)',
@ -1233,6 +1268,73 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': 'Manage folder trust settings',
'Manage permission rules': 'Manage permission rules',
Allow: 'Allow',
Ask: 'Ask',
Deny: 'Deny',
Workspace: 'Workspace',
"Qwen Code won't ask before using allowed tools.":
"Qwen Code won't ask before using allowed tools.",
'Qwen Code will ask before using these tools.':
'Qwen Code will ask before using these tools.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code is not allowed to use denied tools.',
'Manage trusted directories for this workspace.':
'Manage trusted directories for this workspace.',
'Any use of the {{tool}} tool': 'Any use of the {{tool}} tool',
"{{tool}} commands matching '{{pattern}}'":
"{{tool}} commands matching '{{pattern}}'",
'From user settings': 'From user settings',
'From project settings': 'From project settings',
'From session': 'From session',
'Project settings (local)': 'Project settings (local)',
'Saved in .qwen/settings.local.json': 'Saved in .qwen/settings.local.json',
'Project settings': 'Project settings',
'Checked in at .qwen/settings.json': 'Checked in at .qwen/settings.json',
'User settings': 'User settings',
'Saved in at ~/.qwen/settings.json': 'Saved in at ~/.qwen/settings.json',
'Add a new rule…': 'Add a new rule…',
'Add {{type}} permission rule': 'Add {{type}} permission rule',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
'e.g.,': 'e.g.,',
or: 'or',
'Enter permission rule…': 'Enter permission rule…',
'Enter to submit · Esc to cancel': 'Enter to submit · Esc to cancel',
'Where should this rule be saved?': 'Where should this rule be saved?',
'Enter to confirm · Esc to cancel': 'Enter to confirm · Esc to cancel',
'Delete {{type}} rule?': 'Delete {{type}} rule?',
'Are you sure you want to delete this permission rule?':
'Are you sure you want to delete this permission rule?',
'Permissions:': 'Permissions:',
'(←/→ or tab to cycle)': '(←/→ or tab to cycle)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
'Search…': 'Search…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust to manage folder trust settings for this workspace.',
// Workspace directory management
'Add directory…': 'Add directory…',
'Add directory to workspace': 'Add directory to workspace',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
'Enter the path to the directory:': 'Enter the path to the directory:',
'Enter directory path…': 'Enter directory path…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab to complete · Enter to add · Esc to cancel',
'Remove directory?': 'Remove directory?',
'Are you sure you want to remove this directory from the workspace?':
'Are you sure you want to remove this directory from the workspace?',
' (Original working directory)': ' (Original working directory)',
' (from settings)': ' (from settings)',
'Directory does not exist.': 'Directory does not exist.',
'Path is not a directory.': 'Path is not a directory.',
'This directory is already in the workspace.':
'This directory is already in the workspace.',
'Already covered by existing directory: {{dir}}':
'Already covered by existing directory: {{dir}}',
// ============================================================================
// Status Bar
@ -1639,6 +1741,34 @@ export default {
'New model configurations are available for {{region}}. Update now?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Context Usage',
'No API response yet. Send a message to see actual usage.':
'No API response yet. Send a message to see actual usage.',
'Estimated pre-conversation overhead': 'Estimated pre-conversation overhead',
'Context window': 'Context window',
tokens: 'tokens',
Used: 'Used',
Free: 'Free',
'Autocompact buffer': 'Autocompact buffer',
'Usage by category': 'Usage by category',
'System prompt': 'System prompt',
'Built-in tools': 'Built-in tools',
'MCP tools': 'MCP tools',
'Memory files': 'Memory files',
Skills: 'Skills',
Messages: 'Messages',
'Show context window usage breakdown.':
'Show context window usage breakdown.',
'Run /context detail for per-item breakdown.':
'Run /context detail for per-item breakdown.',
'body loaded': 'body loaded',
memory: 'memory',
'{{region}} configuration updated successfully.':
'{{region}} configuration updated successfully.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1673,4 +1803,77 @@ export default {
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigate | Enter: Select | Esc: Cancel',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Authenticate using Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Authenticate using Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Region for Coding Plan (china/global)',
'API key for Coding Plan': 'API key for Coding Plan',
'Show current authentication status': 'Show current authentication status',
'Authentication completed successfully.':
'Authentication completed successfully.',
'Starting Qwen OAuth authentication...':
'Starting Qwen OAuth authentication...',
'Successfully authenticated with Qwen OAuth.':
'Successfully authenticated with Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Failed to authenticate with Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processing Alibaba Cloud Coding Plan authentication...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Successfully authenticated with Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Failed to authenticate with Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Select region for Coding Plan:',
'Enter your Coding Plan API key: ': 'Enter your Coding Plan API key: ',
'Select authentication method:': 'Select authentication method:',
'\n=== Authentication Status ===\n': '\n=== Authentication Status ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ No authentication method configured.\n',
'Run one of the following commands to get started:\n':
'Run one of the following commands to get started:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Or simply run:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interactive authentication setup\n',
'✓ Authentication Method: Qwen OAuth': '✓ Authentication Method: Qwen OAuth',
' Type: Free tier': ' Type: Free tier',
' Limit: Up to 1,000 requests/day': ' Limit: Up to 1,000 requests/day',
' Models: Qwen latest models\n': ' Models: Qwen latest models\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentication Method: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Current Model: {{model}}',
' Config Version: {{version}}': ' Config Version: {{version}}',
' Status: API key configured\n': ' Status: API key configured\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
' Issue: API key not found in environment or settings\n':
' Issue: API key not found in environment or settings\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Run `qwen auth coding-plan` to re-configure.\n',
'✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}',
' Status: Configured\n': ' Status: Configured\n',
'Failed to check authentication status: {{error}}':
'Failed to check authentication status: {{error}}',
'Select an option:': 'Select an option:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
};

View file

@ -85,6 +85,7 @@ export default {
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
'List available Qwen Code tools. Usage: /tools [desc]':
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
'List available skills.': '利用可能なスキルを一覧表示する。',
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
'No tools available': '利用可能なツールはありません',
'View or change the approval mode for tool usage':
@ -328,6 +329,7 @@ export default {
'ワークスペース内のすべてのディレクトリを表示',
'set external editor preference': '外部エディタの設定',
'Manage extensions': '拡張機能を管理',
'Manage installed extensions': 'インストール済みの拡張機能を管理する',
'List active extensions': '有効な拡張機能を一覧表示',
'Update extensions. Usage: update <extension-names>|--all':
'拡張機能を更新。使い方: update <拡張機能名>|--all',
@ -371,6 +373,38 @@ export default {
'{{terminalName}} の設定に失敗しました',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'ターミナルは複数行入力(Shift+Enter と Ctrl+Enter)に最適化されています',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': 'Qwen Code のフックを管理する',
'List all configured hooks': '設定済みのフックをすべて表示する',
'Enable a disabled hook': '無効なフックを有効にする',
'Disable an active hook': '有効なフックを無効にする',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'現在のセッションのメッセージ履歴をファイルにエクスポートする',
'Export session to HTML format': 'セッションを HTML 形式でエクスポートする',
'Export session to JSON format': 'セッションを JSON 形式でエクスポートする',
'Export session to JSONL format (one message per line)':
'セッションを JSONL 形式でエクスポートする1 行に 1 メッセージ)',
'Export session to markdown format':
'セッションを Markdown 形式でエクスポートする',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'チャット履歴からパーソナライズされたプログラミングインサイトを生成する',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': '前のセッションを再開する',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'ツール呼び出しを復元します。これにより、会話とファイルの履歴はそのツール呼び出しが提案された時点の状態に戻ります',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae',
'Terminal "{{terminal}}" is not supported yet.':
@ -751,6 +785,8 @@ export default {
'No, suggest changes (esc)': 'いいえ、変更を提案 (Esc)',
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Always allow in this project': 'このプロジェクトで常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',
@ -871,6 +907,73 @@ export default {
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
// Dialogs - Permissions
'Manage folder trust settings': 'フォルダ信頼設定を管理',
'Manage permission rules': '権限ルールを管理',
Allow: '許可',
Ask: '確認',
Deny: '拒否',
Workspace: 'ワークスペース',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code は許可されたツールを使用する前に確認しません。',
'Qwen Code will ask before using these tools.':
'Qwen Code はこれらのツールを使用する前に確認します。',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code は拒否されたツールを使用できません。',
'Manage trusted directories for this workspace.':
'このワークスペースの信頼済みディレクトリを管理します。',
'Any use of the {{tool}} tool': '{{tool}} ツールのすべての使用',
"{{tool}} commands matching '{{pattern}}'":
"'{{pattern}}' に一致する {{tool}} コマンド",
'From user settings': 'ユーザー設定から',
'From project settings': 'プロジェクト設定から',
'From session': 'セッションから',
'Project settings (local)': 'プロジェクト設定(ローカル)',
'Saved in .qwen/settings.local.json': '.qwen/settings.local.json に保存',
'Project settings': 'プロジェクト設定',
'Checked in at .qwen/settings.json': '.qwen/settings.json にチェックイン',
'User settings': 'ユーザー設定',
'Saved in at ~/.qwen/settings.json': '~/.qwen/settings.json に保存',
'Add a new rule…': '新しいルールを追加…',
'Add {{type}} permission rule': '{{type}}権限ルールを追加',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'権限ルールはツール名で、オプションで括弧内に指定子を付けます。',
'e.g.,': '例:',
or: 'または',
'Enter permission rule…': '権限ルールを入力…',
'Enter to submit · Esc to cancel': 'Enter で送信 · Esc でキャンセル',
'Where should this rule be saved?': 'このルールをどこに保存しますか?',
'Enter to confirm · Esc to cancel': 'Enter で確認 · Esc でキャンセル',
'Delete {{type}} rule?': '{{type}}ルールを削除しますか?',
'Are you sure you want to delete this permission rule?':
'この権限ルールを削除してもよろしいですか?',
'Permissions:': '権限:',
'(←/→ or tab to cycle)': '(←/→ または Tab で切替)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ でナビゲート · Enter で選択 · 入力で検索 · Esc でキャンセル',
'Search…': '検索…',
'Use /trust to manage folder trust settings for this workspace.':
'/trust を使用してこのワークスペースのフォルダ信頼設定を管理します。',
// Workspace directory management
'Add directory…': 'ディレクトリを追加…',
'Add directory to workspace': 'ワークスペースにディレクトリを追加',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code はワークスペース内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code はこのディレクトリ内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
'Enter the path to the directory:': 'ディレクトリのパスを入力してください:',
'Enter directory path…': 'ディレクトリパスを入力…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab で補完 · Enter で追加 · Esc でキャンセル',
'Remove directory?': 'ディレクトリを削除しますか?',
'Are you sure you want to remove this directory from the workspace?':
'このディレクトリをワークスペースから削除してもよろしいですか?',
' (Original working directory)': ' (元の作業ディレクトリ)',
' (from settings)': ' (設定より)',
'Directory does not exist.': 'ディレクトリが存在しません。',
'Path is not a directory.': 'パスはディレクトリではありません。',
'This directory is already in the workspace.':
'このディレクトリはすでにワークスペースに含まれています。',
'Already covered by existing directory: {{dir}}':
'既存のディレクトリによって既にカバーされています: {{dir}}',
// Status Bar
'Using:': '使用中:',
'{{count}} open file': '{{count}} 個のファイルを開いています',
@ -1092,6 +1195,35 @@ export default {
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'{{region}} での認証に成功しました。API キーとモデル設定が settings.json に保存されました(バックアップ済み)。',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'コンテキスト使用量',
'No API response yet. Send a message to see actual usage.':
'API応答はありません。メッセージを送信して実際の使用量を確認してください。',
'Estimated pre-conversation overhead': '推定事前会話オーバーヘッド',
'Context window': 'コンテキストウィンドウ',
tokens: 'トークン',
Used: '使用済み',
Free: '空き',
'Autocompact buffer': '自動圧縮バッファ',
'Usage by category': 'カテゴリ別の使用量',
'System prompt': 'システムプロンプト',
'Built-in tools': '組み込みツール',
'MCP tools': 'MCPツール',
'Memory files': 'メモリファイル',
Skills: 'スキル',
Messages: 'メッセージ',
'Show context window usage breakdown.':
'コンテキストウィンドウの使用状況を表示します。',
'Run /context detail for per-item breakdown.':
'/context detail を実行すると項目ごとの内訳を表示します。',
active: '有効',
'body loaded': '本文読み込み済み',
memory: 'メモリ',
'{{region}} configuration updated successfully.':
'{{region}} の設定が正常に更新されました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1125,4 +1257,76 @@ export default {
'↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-OAuth または Alibaba Cloud Coding Plan で Qwen 認証情報を設定する',
'Authenticate using Qwen OAuth': 'Qwen OAuth で認証する',
'Authenticate using Alibaba Cloud Coding Plan':
'Alibaba Cloud Coding Plan で認証する',
'Region for Coding Plan (china/global)':
'Coding Plan のリージョン (china/global)',
'API key for Coding Plan': 'Coding Plan の API キー',
'Show current authentication status': '現在の認証ステータスを表示',
'Authentication completed successfully.': '認証が正常に完了しました。',
'Starting Qwen OAuth authentication...': 'Qwen OAuth 認証を開始しています...',
'Successfully authenticated with Qwen OAuth.':
'Qwen OAuth での認証に成功しました。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth での認証に失敗しました: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan 認証を処理しています...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Alibaba Cloud Coding Plan での認証に成功しました。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan での認証に失敗しました: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'グローバル',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Coding Plan のリージョンを選択:',
'Enter your Coding Plan API key: ':
'Coding Plan の API キーを入力してください: ',
'Select authentication method:': '認証方法を選択:',
'\n=== Authentication Status ===\n': '\n=== 認証ステータス ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ 認証方法が設定されていません。\n',
'Run one of the following commands to get started:\n':
'以下のコマンドのいずれかを実行して開始してください:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Qwen OAuth で認証(無料)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Alibaba Cloud Coding Plan で認証\n',
'Or simply run:': 'または以下を実行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - インタラクティブ認証セットアップ\n',
'✓ Authentication Method: Qwen OAuth': '✓ 認証方法: Qwen OAuth',
' Type: Free tier': ' タイプ: 無料プラン',
' Limit: Up to 1,000 requests/day': ' 制限: 1日最大1,000リクエスト',
' Models: Qwen latest models\n': ' モデル: Qwen 最新モデル\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 認証方法: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'グローバル - Alibaba Cloud',
' Region: {{region}}': ' リージョン: {{region}}',
' Current Model: {{model}}': ' 現在のモデル: {{model}}',
' Config Version: {{version}}': ' 設定バージョン: {{version}}',
' Status: API key configured\n': ' ステータス: APIキー設定済み\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 認証方法: Alibaba Cloud Coding Plan不完全',
' Issue: API key not found in environment or settings\n':
' 問題: 環境変数または設定にAPIキーが見つかりません\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' `qwen auth coding-plan` を実行して再設定してください。\n',
'✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}',
' Status: Configured\n': ' ステータス: 設定済み\n',
'Failed to check authentication status: {{error}}':
'認証ステータスの確認に失敗しました: {{error}}',
'Select an option:': 'オプションを選択:',
'Raw mode not available. Please run in an interactive terminal.':
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
};

View file

@ -111,6 +111,7 @@ export default {
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
'List available Qwen Code tools. Usage: /tools [desc]':
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
'List available skills.': 'Listar habilidades disponíveis.',
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
'No tools available': 'Nenhuma ferramenta disponível',
'View or change the approval mode for tool usage':
@ -401,6 +402,7 @@ export default {
'Estes editores são suportados atualmente. Note que alguns editores não podem ser usados no modo sandbox.',
'Your preferred editor is:': 'Seu editor preferido é:',
'Manage extensions': 'Gerenciar extensões',
'Manage installed extensions': 'Gerenciar extensões instaladas',
'List active extensions': 'Listar extensões ativas',
'Update extensions. Usage: update <extension-names>|--all':
'Atualizar extensões. Uso: update <nomes-das-extensoes>|--all',
@ -590,6 +592,38 @@ export default {
'Falha ao configurar {{terminalName}}.',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'Seu terminal já está configurado para uma experiência ideal com entrada multilinhas (Shift+Enter e Ctrl+Enter).',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': 'Gerenciar hooks do Qwen Code',
'List all configured hooks': 'Listar todos os hooks configurados',
'Enable a disabled hook': 'Ativar um hook desativado',
'Disable an active hook': 'Desativar um hook ativo',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'Exportar o histórico de mensagens da sessão atual para um arquivo',
'Export session to HTML format': 'Exportar a sessão para o formato HTML',
'Export session to JSON format': 'Exportar a sessão para o formato JSON',
'Export session to JSONL format (one message per line)':
'Exportar a sessão para o formato JSONL (uma mensagem por linha)',
'Export session to markdown format':
'Exportar a sessão para o formato Markdown',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'Gerar insights personalizados de programação a partir do seu histórico de chat',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': 'Retomar uma sessão anterior',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'Restaurar uma chamada de ferramenta. Isso redefinirá o histórico da conversa e dos arquivos para o estado em que a chamada da ferramenta foi sugerida',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'Não foi possível detectar o tipo de terminal. Terminais suportados: VS Code, Cursor, Windsurf e Trae.',
'Terminal "{{terminal}}" is not supported yet.':
@ -1019,6 +1053,8 @@ export default {
"Allow execution of: '{{command}}'?":
"Permitir a execução de: '{{command}}'?",
'Yes, allow always ...': 'Sim, permitir sempre ...',
'Always allow in this project': 'Sempre permitir neste projeto',
'Always allow for this user': 'Sempre permitir para este usuário',
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
'No, keep planning (esc)': 'Não, continuar planejando (esc)',
@ -1185,6 +1221,74 @@ export default {
// ============================================================================
'Manage folder trust settings':
'Gerenciar configurações de confiança de pasta',
'Manage permission rules': 'Gerenciar regras de permissão',
Allow: 'Permitir',
Ask: 'Perguntar',
Deny: 'Negar',
Workspace: 'Área de trabalho',
"Qwen Code won't ask before using allowed tools.":
'O Qwen Code não perguntará antes de usar ferramentas permitidas.',
'Qwen Code will ask before using these tools.':
'O Qwen Code perguntará antes de usar essas ferramentas.',
'Qwen Code is not allowed to use denied tools.':
'O Qwen Code não tem permissão para usar ferramentas negadas.',
'Manage trusted directories for this workspace.':
'Gerenciar diretórios confiáveis para esta área de trabalho.',
'Any use of the {{tool}} tool': 'Qualquer uso da ferramenta {{tool}}',
"{{tool}} commands matching '{{pattern}}'":
"Comandos {{tool}} correspondentes a '{{pattern}}'",
'From user settings': 'Das configurações do usuário',
'From project settings': 'Das configurações do projeto',
'From session': 'Da sessão',
'Project settings (local)': 'Configurações do projeto (local)',
'Saved in .qwen/settings.local.json': 'Salvo em .qwen/settings.local.json',
'Project settings': 'Configurações do projeto',
'Checked in at .qwen/settings.json': 'Registrado em .qwen/settings.json',
'User settings': 'Configurações do usuário',
'Saved in at ~/.qwen/settings.json': 'Salvo em ~/.qwen/settings.json',
'Add a new rule…': 'Adicionar nova regra…',
'Add {{type}} permission rule': 'Adicionar regra de permissão {{type}}',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Regras de permissão são um nome de ferramenta, opcionalmente seguido por um especificador entre parênteses.',
'e.g.,': 'ex.',
or: 'ou',
'Enter permission rule…': 'Insira a regra de permissão…',
'Enter to submit · Esc to cancel': 'Enter para enviar · Esc para cancelar',
'Where should this rule be saved?': 'Onde esta regra deve ser salva?',
'Enter to confirm · Esc to cancel':
'Enter para confirmar · Esc para cancelar',
'Delete {{type}} rule?': 'Excluir regra {{type}}?',
'Are you sure you want to delete this permission rule?':
'Tem certeza de que deseja excluir esta regra de permissão?',
'Permissions:': 'Permissões:',
'(←/→ or tab to cycle)': '(←/→ ou Tab para alternar)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ para navegar · Enter para selecionar · Digite para pesquisar · Esc para cancelar',
'Search…': 'Pesquisar…',
'Use /trust to manage folder trust settings for this workspace.':
'Use /trust para gerenciar as configurações de confiança de pasta desta área de trabalho.',
// Workspace directory management
'Add directory…': 'Adicionar diretório…',
'Add directory to workspace': 'Adicionar diretório à área de trabalho',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'O Qwen Code pode ler arquivos na área de trabalho e fazer edições quando a aceitação automática está ativada.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'O Qwen Code poderá ler arquivos neste diretório e fazer edições quando a aceitação automática está ativada.',
'Enter the path to the directory:': 'Insira o caminho do diretório:',
'Enter directory path…': 'Insira o caminho do diretório…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab para completar · Enter para adicionar · Esc para cancelar',
'Remove directory?': 'Remover diretório?',
'Are you sure you want to remove this directory from the workspace?':
'Tem certeza de que deseja remover este diretório da área de trabalho?',
' (Original working directory)': ' (Diretório de trabalho original)',
' (from settings)': ' (das configurações)',
'Directory does not exist.': 'O diretório não existe.',
'Path is not a directory.': 'O caminho não é um diretório.',
'This directory is already in the workspace.':
'Este diretório já está na área de trabalho.',
'Already covered by existing directory: {{dir}}':
'Já coberto pelo diretório existente: {{dir}}',
// ============================================================================
// Status Bar
@ -1581,6 +1685,35 @@ export default {
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Uso do Contexto',
'No API response yet. Send a message to see actual usage.':
'Ainda não há resposta da API. Envie uma mensagem para ver o uso real.',
'Estimated pre-conversation overhead': 'Sobrecarga estimada pré-conversa',
'Context window': 'Janela de Contexto',
tokens: 'tokens',
Used: 'Usado',
Free: 'Livre',
'Autocompact buffer': 'Buffer de autocompactação',
'Usage by category': 'Uso por categoria',
'System prompt': 'Prompt do sistema',
'Built-in tools': 'Ferramentas integradas',
'MCP tools': 'Ferramentas MCP',
'Memory files': 'Arquivos de memória',
Skills: 'Habilidades',
Messages: 'Mensagens',
'Show context window usage breakdown.':
'Exibe a divisão de uso da janela de contexto.',
'Run /context detail for per-item breakdown.':
'Execute /context detail para detalhamento por item.',
active: 'ativo',
'body loaded': 'conteúdo carregado',
memory: 'memória',
'{{region}} configuration updated successfully.':
'Configuração do {{region}} atualizada com sucesso.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1616,4 +1749,78 @@ export default {
'↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configurar autenticação Qwen com Qwen-OAuth ou Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Autenticar usando Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Autenticar usando Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Região para Coding Plan (china/global)',
'API key for Coding Plan': 'Chave de API para Coding Plan',
'Show current authentication status': 'Mostrar status atual de autenticação',
'Authentication completed successfully.':
'Autenticação concluída com sucesso.',
'Starting Qwen OAuth authentication...':
'Iniciando autenticação Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Autenticado com sucesso via Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Falha ao autenticar com Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processando autenticação Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Autenticado com sucesso via Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Falha ao autenticar com Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Selecione a região para Coding Plan:',
'Enter your Coding Plan API key: ':
'Insira sua chave de API do Coding Plan: ',
'Select authentication method:': 'Selecione o método de autenticação:',
'\n=== Authentication Status ===\n': '\n=== Status de Autenticação ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Nenhum método de autenticação configurado.\n',
'Run one of the following commands to get started:\n':
'Execute um dos seguintes comandos para começar:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Autenticar com Qwen OAuth (gratuito)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Autenticar com Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Ou simplesmente execute:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Configuração interativa de autenticação\n',
'✓ Authentication Method: Qwen OAuth': '✓ Método de autenticação: Qwen OAuth',
' Type: Free tier': ' Tipo: Gratuito',
' Limit: Up to 1,000 requests/day': ' Limite: Até 1.000 solicitações/dia',
' Models: Qwen latest models\n': ' Modelos: Modelos Qwen mais recentes\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Método de autenticação: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Região: {{region}}',
' Current Model: {{model}}': ' Modelo atual: {{model}}',
' Config Version: {{version}}': ' Versão da configuração: {{version}}',
' Status: API key configured\n': ' Status: Chave de API configurada\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)',
' Issue: API key not found in environment or settings\n':
' Problema: Chave de API não encontrada no ambiente ou configurações\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Execute `qwen auth coding-plan` para reconfigurar.\n',
'✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}',
' Status: Configured\n': ' Status: Configurado\n',
'Failed to check authentication status: {{error}}':
'Falha ao verificar status de autenticação: {{error}}',
'Select an option:': 'Selecione uma opção:',
'Raw mode not available. Please run in an interactive terminal.':
'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
};

View file

@ -119,6 +119,7 @@ export default {
'Анализ проекта и создание адаптированного файла QWEN.md',
'List available Qwen Code tools. Usage: /tools [desc]':
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
'List available skills.': 'Показать доступные навыки.',
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
'No tools available': 'Нет доступных инструментов',
'View or change the approval mode for tool usage':
@ -398,6 +399,7 @@ export default {
'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.',
'Your preferred editor is:': 'Ваш предпочитаемый редактор:',
'Manage extensions': 'Управление расширениями',
'Manage installed extensions': 'Управлять установленными расширениями',
'List active extensions': 'Показать активные расширения',
'Update extensions. Usage: update <extension-names>|--all':
'Обновить расширения. Использование: update <extension-names>|--all',
@ -596,6 +598,38 @@ export default {
'Не удалось настроить {{terminalName}}.',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': 'Управлять хуками Qwen Code',
'List all configured hooks': 'Показать все настроенные хуки',
'Enable a disabled hook': 'Включить отключенный хук',
'Disable an active hook': 'Отключить активный хук',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'Экспортировать историю сообщений текущей сессии в файл',
'Export session to HTML format': 'Экспортировать сессию в формат HTML',
'Export session to JSON format': 'Экспортировать сессию в формат JSON',
'Export session to JSONL format (one message per line)':
'Экспортировать сессию в формат JSONL (одно сообщение на строку)',
'Export session to markdown format':
'Экспортировать сессию в формат Markdown',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'Создать персонализированные инсайты по программированию на основе истории чата',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': 'Продолжить предыдущую сессию',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'Восстановить вызов инструмента. Это вернет историю разговора и файлов к состоянию на момент, когда был предложен этот вызов инструмента',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.',
'Terminal "{{terminal}}" is not supported yet.':
@ -944,6 +978,8 @@ export default {
'No, suggest changes (esc)': 'Нет, предложить изменения (esc)',
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
'Yes, allow always ...': 'Да, всегда разрешать ...',
'Always allow in this project': 'Всегда разрешать в этом проекте',
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',
@ -1108,6 +1144,74 @@ export default {
// Диалоги - Разрешения
// ============================================================================
'Manage folder trust settings': 'Управление настройками доверия к папкам',
'Manage permission rules': 'Управление правилами разрешений',
Allow: 'Разрешить',
Ask: 'Спросить',
Deny: 'Запретить',
Workspace: 'Рабочая область',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code не будет спрашивать перед использованием разрешённых инструментов.',
'Qwen Code will ask before using these tools.':
'Qwen Code спросит перед использованием этих инструментов.',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code не может использовать запрещённые инструменты.',
'Manage trusted directories for this workspace.':
'Управление доверенными каталогами для этой рабочей области.',
'Any use of the {{tool}} tool': 'Любое использование инструмента {{tool}}',
"{{tool}} commands matching '{{pattern}}'":
"Команды {{tool}}, соответствующие '{{pattern}}'",
'From user settings': 'Из пользовательских настроек',
'From project settings': 'Из настроек проекта',
'From session': 'Из сессии',
'Project settings (local)': 'Настройки проекта (локальные)',
'Saved in .qwen/settings.local.json': 'Сохранено в .qwen/settings.local.json',
'Project settings': 'Настройки проекта',
'Checked in at .qwen/settings.json': 'Зафиксировано в .qwen/settings.json',
'User settings': 'Пользовательские настройки',
'Saved in at ~/.qwen/settings.json': 'Сохранено в ~/.qwen/settings.json',
'Add a new rule…': 'Добавить новое правило…',
'Add {{type}} permission rule': 'Добавить правило разрешения {{type}}',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'Правила разрешений — это имя инструмента, за которым может следовать спецификатор в скобках.',
'e.g.,': 'напр.',
or: 'или',
'Enter permission rule…': 'Введите правило разрешения…',
'Enter to submit · Esc to cancel': 'Enter для отправки · Esc для отмены',
'Where should this rule be saved?': 'Где сохранить это правило?',
'Enter to confirm · Esc to cancel':
'Enter для подтверждения · Esc для отмены',
'Delete {{type}} rule?': 'Удалить правило {{type}}?',
'Are you sure you want to delete this permission rule?':
'Вы уверены, что хотите удалить это правило разрешения?',
'Permissions:': 'Разрешения:',
'(←/→ or tab to cycle)': '(←/→ или Tab для переключения)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'↑↓ навигация · Enter выбор · Ввод для поиска · Esc отмена',
'Search…': 'Поиск…',
'Use /trust to manage folder trust settings for this workspace.':
'Используйте /trust для управления настройками доверия к папкам этой рабочей области.',
// Workspace directory management
'Add directory…': 'Добавить каталог…',
'Add directory to workspace': 'Добавить каталог в рабочую область',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code может читать файлы в рабочей области и вносить правки, когда автоприём правок включён.',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code сможет читать файлы в этом каталоге и вносить правки, когда автоприём правок включён.',
'Enter the path to the directory:': 'Введите путь к каталогу:',
'Enter directory path…': 'Введите путь к каталогу…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab для завершения · Enter для добавления · Esc для отмены',
'Remove directory?': 'Удалить каталог?',
'Are you sure you want to remove this directory from the workspace?':
'Вы уверены, что хотите удалить этот каталог из рабочей области?',
' (Original working directory)': ' (Исходный рабочий каталог)',
' (from settings)': ' (из настроек)',
'Directory does not exist.': 'Каталог не существует.',
'Path is not a directory.': 'Путь не является каталогом.',
'This directory is already in the workspace.':
'Этот каталог уже есть в рабочей области.',
'Already covered by existing directory: {{dir}}':
'Уже охвачен существующим каталогом: {{dir}}',
// ============================================================================
// Строка состояния
@ -1519,6 +1623,32 @@ export default {
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Использование контекста',
'No API response yet. Send a message to see actual usage.':
'Пока нет ответа от API. Отправьте сообщение, чтобы увидеть фактическое использование.',
'Estimated pre-conversation overhead':
'Оценочные накладные расходы перед беседой',
'Context window': 'Контекстное окно',
tokens: 'токенов',
Used: 'Использовано',
Free: 'Свободно',
'Autocompact buffer': 'Буфер автоупаковки',
'Usage by category': 'Использование по категориям',
'System prompt': 'Системная подсказка',
'Built-in tools': 'Встроенные инструменты',
'MCP tools': 'Инструменты MCP',
'Memory files': 'Файлы памяти',
Skills: 'Навыки',
Messages: 'Сообщения',
'Show context window usage breakdown.':
'Показать разбивку использования контекстного окна.',
'Run /context detail for per-item breakdown.':
'Выполните /context detail для детализации по элементам.',
active: 'активно',
'body loaded': 'содержимое загружено',
memory: 'память',
// MCP Management Dialog
// ============================================================================
'MCP Management': 'Управление MCP',
@ -1628,4 +1758,77 @@ export default {
'↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Навигация | Enter: Выбор | Esc: Отмена',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Настроить аутентификацию Qwen через Qwen-OAuth или Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Аутентификация через Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Аутентификация через Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Регион для Coding Plan (china/global)',
'API key for Coding Plan': 'API-ключ для Coding Plan',
'Show current authentication status':
'Показать текущий статус аутентификации',
'Authentication completed successfully.': 'Аутентификация успешно завершена.',
'Starting Qwen OAuth authentication...':
'Запуск аутентификации Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Успешная аутентификация через Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Ошибка аутентификации через Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Обработка аутентификации Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Успешная аутентификация через Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Ошибка аутентификации через Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Глобальный',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Выберите регион для Coding Plan:',
'Enter your Coding Plan API key: ': 'Введите ваш API-ключ Coding Plan: ',
'Select authentication method:': 'Выберите метод аутентификации:',
'\n=== Authentication Status ===\n': '\n=== Статус аутентификации ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Метод аутентификации не настроен.\n',
'Run one of the following commands to get started:\n':
'Выполните одну из следующих команд для начала:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Аутентификация через Qwen OAuth (бесплатно)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Аутентификация через Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Или просто выполните:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Интерактивная настройка аутентификации\n',
'✓ Authentication Method: Qwen OAuth': '✓ Метод аутентификации: Qwen OAuth',
' Type: Free tier': ' Тип: Бесплатный',
' Limit: Up to 1,000 requests/day': ' Лимит: До 1 000 запросов/день',
' Models: Qwen latest models\n': ' Модели: Последние модели Qwen\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Метод аутентификации: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Глобальный - Alibaba Cloud',
' Region: {{region}}': ' Регион: {{region}}',
' Current Model: {{model}}': ' Текущая модель: {{model}}',
' Config Version: {{version}}': ' Версия конфигурации: {{version}}',
' Status: API key configured\n': ' Статус: API-ключ настроен\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)',
' Issue: API key not found in environment or settings\n':
' Проблема: API-ключ не найден в окружении или настройках\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Выполните `qwen auth coding-plan` для повторной настройки.\n',
'✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}',
' Status: Configured\n': ' Статус: Настроено\n',
'Failed to check authentication status: {{error}}':
'Не удалось проверить статус аутентификации: {{error}}',
'Select an option:': 'Выберите вариант:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
};

View file

@ -116,6 +116,7 @@ export default {
'分析项目并创建定制的 QWEN.md 文件',
'List available Qwen Code tools. Usage: /tools [desc]':
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
'List available skills.': '列出可用技能。',
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
'No tools available': '没有可用工具',
'View or change the approval mode for tool usage':
@ -437,6 +438,7 @@ export default {
'当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。',
'Your preferred editor is:': '您的首选编辑器是:',
'Manage extensions': '管理扩展',
'Manage installed extensions': '管理已安装的扩展',
'List active extensions': '列出活动扩展',
'Update extensions. Usage: update <extension-names>|--all':
'更新扩展。用法update <extension-names>|--all',
@ -623,6 +625,37 @@ export default {
'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失败。',
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
'您的终端已配置为支持多行输入Shift+Enter 和 Ctrl+Enter的最佳体验。',
// ============================================================================
// Commands - Hooks
// ============================================================================
'Manage Qwen Code hooks': '管理 Qwen Code Hook',
'List all configured hooks': '列出所有已配置的 Hook',
'Enable a disabled hook': '启用已禁用的 Hook',
'Disable an active hook': '禁用已启用的 Hook',
// ============================================================================
// Commands - Session Export
// ============================================================================
'Export current session message history to a file':
'将当前会话的消息记录导出到文件',
'Export session to HTML format': '将会话导出为 HTML 文件',
'Export session to JSON format': '将会话导出为 JSON 文件',
'Export session to JSONL format (one message per line)':
'将会话导出为 JSONL 文件(每行一条消息)',
'Export session to markdown format': '将会话导出为 Markdown 文件',
// ============================================================================
// Commands - Insights
// ============================================================================
'generate personalized programming insights from your chat history':
'根据你的聊天记录生成个性化编程洞察',
// ============================================================================
// Commands - Session History
// ============================================================================
'Resume a previous session': '恢复先前会话',
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
'恢复某次工具调用。这将把对话与文件历史重置到提出该工具调用建议时的状态',
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
'无法检测终端类型。支持的终端VS Code、Cursor、Windsurf 和 Trae。',
'Terminal "{{terminal}}" is not supported yet.':
@ -1010,6 +1043,8 @@ export default {
'No, suggest changes (esc)': '否,建议更改 (esc)',
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'",
'Yes, allow always ...': '是,总是允许 ...',
'Always allow in this project': '在本项目中总是允许',
'Always allow for this user': '对该用户总是允许',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',
@ -1163,6 +1198,71 @@ export default {
// Dialogs - Permissions
// ============================================================================
'Manage folder trust settings': '管理文件夹信任设置',
'Manage permission rules': '管理权限规则',
Allow: '允许',
Ask: '询问',
Deny: '拒绝',
Workspace: '工作区',
"Qwen Code won't ask before using allowed tools.":
'Qwen Code 使用已允许的工具前不会询问。',
'Qwen Code will ask before using these tools.':
'Qwen Code 使用这些工具前会先询问。',
'Qwen Code is not allowed to use denied tools.':
'Qwen Code 不允许使用被拒绝的工具。',
'Manage trusted directories for this workspace.':
'管理此工作区的受信任目录。',
'Any use of the {{tool}} tool': '{{tool}} 工具的任何使用',
"{{tool}} commands matching '{{pattern}}'":
"匹配 '{{pattern}}' 的 {{tool}} 命令",
'From user settings': '来自用户设置',
'From project settings': '来自项目设置',
'From session': '来自会话',
'Project settings (local)': '项目设置(本地)',
'Saved in .qwen/settings.local.json': '保存在 .qwen/settings.local.json',
'Project settings': '项目设置',
'Checked in at .qwen/settings.json': '保存在 .qwen/settings.json',
'User settings': '用户设置',
'Saved in at ~/.qwen/settings.json': '保存在 ~/.qwen/settings.json',
'Add a new rule…': '添加新规则…',
'Add {{type}} permission rule': '添加{{type}}权限规则',
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
'权限规则是一个工具名称,可选地后跟括号中的限定符。',
'e.g.,': '例如',
or: '或',
'Enter permission rule…': '输入权限规则…',
'Enter to submit · Esc to cancel': '回车提交 · Esc 取消',
'Where should this rule be saved?': '此规则应保存在哪里?',
'Enter to confirm · Esc to cancel': '回车确认 · Esc 取消',
'Delete {{type}} rule?': '删除{{type}}规则?',
'Are you sure you want to delete this permission rule?':
'确定要删除此权限规则吗?',
'Permissions:': '权限:',
'(←/→ or tab to cycle)': '(←/→ 或 tab 切换)',
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
'按 ↑↓ 导航 · 回车选择 · 输入搜索 · Esc 取消',
'Search…': '搜索…',
'Use /trust to manage folder trust settings for this workspace.':
'使用 /trust 管理此工作区的文件夹信任设置。',
// Workspace directory management
'Add directory…': '添加目录…',
'Add directory to workspace': '添加工作区目录',
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
'Qwen Code 可以读取工作区中的文件,并在自动接受编辑模式开启时进行编辑。',
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
'Qwen Code 将能够读取此目录中的文件,并在自动接受编辑模式开启时进行编辑。',
'Enter the path to the directory:': '输入目录路径:',
'Enter directory path…': '输入目录路径…',
'Tab to complete · Enter to add · Esc to cancel':
'Tab 补全 · 回车添加 · Esc 取消',
'Remove directory?': '删除目录?',
'Are you sure you want to remove this directory from the workspace?':
'确定要将此目录从工作区中移除吗?',
' (Original working directory)': ' (原始工作目录)',
' (from settings)': ' (来自设置)',
'Directory does not exist.': '目录不存在。',
'Path is not a directory.': '路径不是目录。',
'This directory is already in the workspace.': '此目录已在工作区中。',
'Already covered by existing directory: {{dir}}': '已被现有目录覆盖:{{dir}}',
// ============================================================================
// Status Bar
@ -1463,6 +1563,33 @@ export default {
'{{region}} 有新的模型配置可用。是否立即更新?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json已备份。',
// ============================================================================
// Context Usage
// ============================================================================
'Context Usage': '上下文使用情况',
'Context window': '上下文窗口',
Used: '已用',
Free: '空闲',
'Autocompact buffer': '自动压缩缓冲区',
'Usage by category': '分类用量',
'System prompt': '系统提示',
'Built-in tools': '内置工具',
'MCP tools': 'MCP 工具',
'Memory files': '记忆文件',
Skills: '技能',
Messages: '消息',
tokens: 'tokens',
'Estimated pre-conversation overhead': '预估对话前开销',
'No API response yet. Send a message to see actual usage.':
'暂无 API 响应。发送消息以查看实际使用情况。',
'Show context window usage breakdown.': '显示上下文窗口使用情况分解。',
'Run /context detail for per-item breakdown.':
'运行 /context detail 查看详细分解。',
'body loaded': '内容已加载',
memory: '记忆',
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
@ -1493,4 +1620,72 @@ export default {
'↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: 导航 | Enter: 选择 | Esc: 取消',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'使用 Qwen OAuth 或阿里云百炼 Coding Plan 配置 Qwen 认证信息',
'Authenticate using Qwen OAuth': '使用 Qwen OAuth 进行认证',
'Authenticate using Alibaba Cloud Coding Plan':
'使用阿里云百炼 Coding Plan 进行认证',
'Region for Coding Plan (china/global)': 'Coding Plan 区域 (china/global)',
'API key for Coding Plan': 'Coding Plan 的 API 密钥',
'Show current authentication status': '显示当前认证状态',
'Authentication completed successfully.': '认证完成。',
'Starting Qwen OAuth authentication...': '正在启动 Qwen OAuth 认证...',
'Successfully authenticated with Qwen OAuth.': '已成功通过 Qwen OAuth 认证。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth 认证失败:{{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'正在处理阿里云百炼 Coding Plan 认证...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'已成功通过阿里云百炼 Coding Plan 认证。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan 认证失败:{{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: '全球',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': '选择 Coding Plan 区域:',
'Enter your Coding Plan API key: ': '请输入您的 Coding Plan API 密钥:',
'Select authentication method:': '选择认证方式:',
'\n=== Authentication Status ===\n': '\n=== 认证状态 ===\n',
'⚠️ No authentication method configured.\n': '⚠️ 未配置认证方式。\n',
'Run one of the following commands to get started:\n':
'运行以下命令之一开始配置:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - 使用 Qwen OAuth 认证(免费)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - 使用阿里云百炼 Coding Plan 认证\n',
'Or simply run:': '或者直接运行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - 交互式认证配置\n',
'✓ Authentication Method: Qwen OAuth': '✓ 认证方式Qwen OAuth',
' Type: Free tier': ' 类型:免费版',
' Limit: Up to 1,000 requests/day': ' 限额:每天最多 1,000 次请求',
' Models: Qwen latest models\n': ' 模型Qwen 最新模型\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 认证方式:阿里云百炼 Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': '全球 - Alibaba Cloud',
' Region: {{region}}': ' 区域:{{region}}',
' Current Model: {{model}}': ' 当前模型:{{model}}',
' Config Version: {{version}}': ' 配置版本:{{version}}',
' Status: API key configured\n': ' 状态API 密钥已配置\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 认证方式:阿里云百炼 Coding Plan不完整',
' Issue: API key not found in environment or settings\n':
' 问题:在环境变量或设置中未找到 API 密钥\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' 运行 `qwen auth coding-plan` 重新配置。\n',
'✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}',
' Status: Configured\n': ' 状态:已配置\n',
'Failed to check authentication status: {{error}}':
'检查认证状态失败:{{error}}',
'Select an option:': '请选择:',
'Raw mode not available. Please run in an interactive terminal.':
'原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n',
};

View file

@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter {
return;
}
if (lastBlock.type === 'text') {
const index = state.blocks.length - 1;
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
} else if (lastBlock.type === 'thinking') {
const index = state.blocks.length - 1;
const index = state.blocks.length - 1;
if (!state.openBlocks.has(index)) {
return;
}
if (lastBlock.type === 'text' || lastBlock.type === 'thinking') {
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
}
@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter {
}
const message = this.buildMessage(parentToolUseId);
this.emitMessageImpl(message);
if (state.messageStarted) {
this.emitMessageImpl(message);
}
return message;
}
@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId: string,
): CLIAssistantMessage {
const state = this.getMessageState(parentToolUseId);
const message = this.finalizeAssistantMessageInternal(
state,
parentToolUseId,
);
this.updateLastAssistantMessage(message);
return message;
return this.finalizeAssistantMessageInternal(state, parentToolUseId);
}
/**

View file

@ -52,12 +52,10 @@ export class JsonOutputAdapter
}
finalizeAssistantMessage(): CLIAssistantMessage {
const message = this.finalizeAssistantMessageInternal(
return this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
this.updateLastAssistantMessage(message);
return message;
}
emitResult(options: ResultOptions): void {

View file

@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => {
'Message not started',
);
});
it('should not emit empty assistant message when started but no content processed', () => {
stdoutWriteSpy.mockClear();
adapter.finalizeAssistantMessage();
const assistantCalls = stdoutWriteSpy.mock.calls.filter(
(call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'assistant';
} catch {
return false;
}
},
);
expect(assistantCalls).toHaveLength(0);
});
});
describe('emitResult', () => {
@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => {
});
});
describe('message_id in stream events', () => {
describe('content_block event identification', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
adapter.startAssistantMessage();
});
it('should include message_id in stream events after message starts', () => {
it('should not include message_id in content_block events', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
// Process another event to ensure messageStarted is true
adapter.processEvent({
type: GeminiEventType.Content,
value: 'More',
});
const calls = stdoutWriteSpy.mock.calls;
// Find all delta events
const deltaCalls = calls.filter((call: unknown[]) => {
const contentBlockCalls = calls.filter((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
(parsed.event.type === 'content_block_start' ||
parsed.event.type === 'content_block_delta' ||
parsed.event.type === 'content_block_stop')
);
} catch {
return false;
}
});
expect(deltaCalls.length).toBeGreaterThan(0);
// The second delta event should have message_id (after messageStarted becomes true)
// message_id is added to the event object, so check parsed.event.message_id
if (deltaCalls.length > 1) {
const secondDelta = JSON.parse(
(deltaCalls[1] as unknown[])[0] as string,
);
// message_id is on the enriched event object
expect(
secondDelta.event.message_id || secondDelta.message_id,
).toBeTruthy();
} else {
// If only one delta, check if message_id exists
const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string);
// message_id is added when messageStarted is true
// First event may or may not have it, but subsequent ones should
expect(delta.event.message_id || delta.message_id).toBeTruthy();
expect(contentBlockCalls.length).toBeGreaterThan(0);
for (const call of contentBlockCalls) {
const parsed = JSON.parse((call as unknown[])[0] as string);
expect(parsed.event.message_id).toBeUndefined();
}
});
it('should identify content_block events by session_id and index', () => {
adapter.processEvent({
type: GeminiEventType.Content,
value: 'Text',
});
const calls = stdoutWriteSpy.mock.calls;
const blockStartCall = calls.find((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_start'
);
} catch {
return false;
}
});
expect(blockStartCall).toBeDefined();
const parsed = JSON.parse((blockStartCall as unknown[])[0] as string);
expect(parsed.session_id).toBe('test-session-id');
expect(typeof parsed.event.index).toBe('number');
});
});
describe('multiple text blocks', () => {

View file

@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
private mainTurnMessageStartEmitted = false;
constructor(
config: Config,
private readonly includePartialMessages: boolean,
@ -68,29 +70,27 @@ export class StreamJsonOutputAdapter
return this.includePartialMessages;
}
override startAssistantMessage(): void {
this.mainTurnMessageStartEmitted = false;
super.startAssistantMessage();
}
finalizeAssistantMessage(): CLIAssistantMessage {
const state = this.mainAgentMessageState;
if (state.finalized) {
return this.buildMessage(null);
}
state.finalized = true;
this.finalizePendingBlocks(state, null);
const orderedOpenBlocks = Array.from(state.openBlocks).sort(
(a, b) => a - b,
const message = this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
for (const index of orderedOpenBlocks) {
this.onBlockClosed(state, index, null);
this.closeBlock(state, index);
if (this.mainTurnMessageStartEmitted && this.includePartialMessages) {
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
event: { type: 'message_stop' },
};
this.emitMessageImpl(partial);
}
if (state.messageStarted && this.includePartialMessages) {
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
}
const message = this.buildMessage(null);
this.updateLastAssistantMessage(message);
this.emitMessageImpl(message);
this.mainTurnMessageStartEmitted = false;
return message;
}
@ -249,14 +249,15 @@ export class StreamJsonOutputAdapter
/**
* Overrides base class hook to emit message_start event when message is started.
* Only emits for main agent, not for subagents.
* Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted),
* so block-type transitions inside a single turn do not produce spurious message_start events.
*/
protected override onEnsureMessageStarted(
state: MessageState,
parentToolUseId: string | null,
): void {
// Only emit message_start for main agent, not for subagents
if (parentToolUseId === null) {
if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) {
this.mainTurnMessageStartEmitted = true;
this.emitStreamEventIfEnabled(
{
type: 'message_start',
@ -264,6 +265,7 @@ export class StreamJsonOutputAdapter
id: state.messageId!,
role: 'assistant',
model: this.config.getModel(),
content: [],
},
},
null,
@ -311,19 +313,12 @@ export class StreamJsonOutputAdapter
return;
}
const state = this.getMessageState(parentToolUseId);
const enrichedEvent = state.messageStarted
? ({ ...event, message_id: state.messageId } as StreamEvent & {
message_id: string;
})
: event;
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: parentToolUseId,
event: enrichedEvent,
event,
};
this.emitMessageImpl(partial);
}

View file

@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
id: string;
role: 'assistant';
model: string;
content: [];
};
}

View file

@ -20,6 +20,7 @@ import {
uiTelemetryService,
FatalInputError,
ApprovalMode,
SendMessageType,
} from '@qwen-code/qwen-code-core';
import type { Part } from '@google/genai';
import { runNonInteractive } from './nonInteractiveCli.js';
@ -250,7 +251,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(mockShutdownTelemetry).toHaveBeenCalled();
@ -300,21 +301,21 @@ describe('runNonInteractive', () => {
outputUpdateHandler: expect.any(Function),
}),
);
// Verify first call has isContinuation: false
// Verify first call has type: UserQuery
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
1,
[{ text: 'Use a tool' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
// Verify second call (after tool execution) has isContinuation: true
// Verify second call (after tool execution) has type: ToolResult
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
2,
[{ text: 'Tool response' }],
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
});
@ -383,7 +384,7 @@ describe('runNonInteractive', () => {
],
expect.any(AbortSignal),
'prompt-id-3',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
});
@ -507,7 +508,7 @@ describe('runNonInteractive', () => {
processedParts,
expect.any(AbortSignal),
'prompt-id-7',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
// 6. Assert the final output is correct
@ -539,7 +540,7 @@ describe('runNonInteractive', () => {
[{ text: 'Test input' }],
expect.any(AbortSignal),
'prompt-id-1',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
// JSON adapter emits array of messages, last one is result with stats
@ -694,7 +695,7 @@ describe('runNonInteractive', () => {
[{ text: 'Empty response test' }],
expect.any(AbortSignal),
'prompt-id-empty',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
// JSON adapter emits array of messages, last one is result with stats
@ -881,7 +882,7 @@ describe('runNonInteractive', () => {
[{ text: 'Prompt from command' }],
expect.any(AbortSignal),
'prompt-id-slash',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
@ -941,7 +942,7 @@ describe('runNonInteractive', () => {
[{ text: '/unknowncommand' }],
expect.any(AbortSignal),
'prompt-id-unknown',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
@ -1299,7 +1300,7 @@ describe('runNonInteractive', () => {
[{ text: 'Message from stream-json input' }],
expect.any(AbortSignal),
'prompt-envelope',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
});
@ -1775,7 +1776,7 @@ describe('runNonInteractive', () => {
[{ text: 'Simple string content' }],
expect.any(AbortSignal),
'prompt-string-content',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
// UserMessage with array of text blocks
@ -1808,7 +1809,7 @@ describe('runNonInteractive', () => {
[{ text: 'First part' }, { text: 'Second part' }],
expect.any(AbortSignal),
'prompt-blocks-content',
{ isContinuation: false },
{ type: SendMessageType.UserQuery },
);
});
});

View file

@ -19,6 +19,7 @@ import {
uiTelemetryService,
parseAndFormatApiError,
createDebugLogger,
SendMessageType,
} from '@qwen-code/qwen-code-core';
import type { Content, Part, PartListUnion } from '@google/genai';
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
@ -265,7 +266,11 @@ export async function runNonInteractive(
currentMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{ isContinuation: !isFirstTurn },
{
type: isFirstTurn
? SendMessageType.UserQuery
: SendMessageType.ToolResult,
},
);
isFirstTurn = false;
@ -385,6 +390,16 @@ export async function runNonInteractive(
}
}
} catch (error) {
// Ensure message_start / message_stop (and content_block events) are
// properly paired even when an error aborts the turn mid-stream.
// The call is safe when no message was started (throws → caught) or
// when already finalized (idempotent guard inside the adapter).
try {
adapter.finalizeAssistantMessage();
} catch {
// Expected when no message was started or already finalized
}
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
const metrics = uiTelemetryService.getMetrics();

View file

@ -37,12 +37,33 @@ vi.mock('../ui/commands/ideCommand.js', async () => {
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn(),
}));
vi.mock('../ui/commands/trustCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
trustCommand: {
name: 'trust',
description: 'Trust command',
kind: CommandKind.BUILT_IN,
},
};
});
vi.mock('../ui/commands/permissionsCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
permissionsCommand: {
name: 'permissions',
description: 'Permissions command',
description: 'Manage permission rules',
kind: CommandKind.BUILT_IN,
},
};
});
vi.mock('../ui/commands/hooksCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
hooksCommand: {
name: 'hooks',
description: 'Hooks command',
kind: CommandKind.BUILT_IN,
},
};
@ -100,6 +121,7 @@ describe('BuiltinCommandLoader', () => {
mockConfig = {
getFolderTrust: vi.fn().mockReturnValue(true),
getUseModelRouter: () => false,
getEnableHooks: vi.fn().mockReturnValue(true),
} as unknown as Config;
restoreCommandMock.mockReturnValue({
@ -162,19 +184,19 @@ describe('BuiltinCommandLoader', () => {
expect(modelCmd).toBeDefined();
});
it('should include permissions command when folder trust is enabled', async () => {
it('should include trust command when folder trust is enabled', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const permissionsCmd = commands.find((c) => c.name === 'permissions');
expect(permissionsCmd).toBeDefined();
const trustCmd = commands.find((c) => c.name === 'trust');
expect(trustCmd).toBeDefined();
});
it('should exclude permissions command when folder trust is disabled', async () => {
it('should exclude trust command when folder trust is disabled', async () => {
(mockConfig.getFolderTrust as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const permissionsCmd = commands.find((c) => c.name === 'permissions');
expect(permissionsCmd).toBeUndefined();
const trustCmd = commands.find((c) => c.name === 'trust');
expect(trustCmd).toBeUndefined();
});
it('should always include modelCommand', async () => {
@ -184,4 +206,19 @@ describe('BuiltinCommandLoader', () => {
expect(modelCmd).toBeDefined();
expect(modelCmd?.name).toBe('model');
});
it('should include hooks command when enableHooks is true', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeDefined();
});
it('should exclude hooks command when enableHooks is false', async () => {
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands(new AbortController().signal);
const hooksCmd = commands.find((c) => c.name === 'hooks');
expect(hooksCmd).toBeUndefined();
});
});

View file

@ -9,12 +9,14 @@ import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { btwCommand } from '../ui/commands/btwCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { contextCommand } from '../ui/commands/contextCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
@ -30,6 +32,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { modelCommand } from '../ui/commands/modelCommand.js';
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
import { trustCommand } from '../ui/commands/trustCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { resumeCommand } from '../ui/commands/resumeCommand.js';
@ -62,12 +65,14 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
arenaCommand,
approvalModeCommand,
authCommand,
btwCommand,
bugCommand,
clearCommand,
compressCommand,
contextCommand,
copyCommand,
docsCommand,
directoryCommand,
@ -75,14 +80,15 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
hooksCommand,
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
languageCommand,
mcpCommand,
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
permissionsCommand,
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,

View file

@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
})),
};
});
@ -1137,6 +1138,102 @@ describe('DataProcessor', () => {
});
});
describe('generateQualitativeInsights', () => {
const mockMetrics = {
totalSessions: 5,
totalMessages: 50,
totalHours: 2,
heatmap: { '2025-01-15': 3 },
topTools: [['read_file', 10]] as Array<[string, number]>,
activeDays: 1,
activeHours: { '10': 5 },
totalLinesAdded: 100,
totalLinesRemoved: 50,
totalFiles: 10,
streak: { currentStreak: 1, longestStreak: 1, dates: [] },
} as unknown as Omit<InsightData, 'facets' | 'qualitative'>;
const mockFacets: SessionFacets[] = [
{
session_id: 'test-1',
underlying_goal: 'Fix bug',
goal_categories: { debugging: 1 },
outcome: 'fully_achieved',
user_satisfaction_counts: { satisfied: 1 },
Qwen_helpfulness: 'very_helpful',
session_type: 'single_task',
friction_counts: {},
friction_detail: '',
primary_success: 'correct_code_edits',
brief_summary: 'Fixed a bug',
},
];
it('should return partial qualitative data when some LLM calls fail', async () => {
let callIndex = 0;
mockGenerateJson.mockImplementation(() => {
callIndex++;
if (callIndex % 2 === 0) {
return Promise.reject(new Error('LLM timeout'));
}
return Promise.resolve({ intro: 'test', areas: [], opportunities: [] });
});
const result = await (
dataProcessor as unknown as {
generateQualitativeInsights(
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
facets: SessionFacets[],
): Promise<
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
| undefined
>;
}
).generateQualitativeInsights(mockMetrics, mockFacets);
expect(result).toBeDefined();
expect(result!.impressiveWorkflows).toBeDefined();
expect(result!.projectAreas).toBeUndefined();
expect(result!.futureOpportunities).toBeDefined();
expect(result!.frictionPoints).toBeUndefined();
});
it('should return undefined when facets are empty', async () => {
const result = await (
dataProcessor as unknown as {
generateQualitativeInsights(
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
facets: SessionFacets[],
): Promise<
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
| undefined
>;
}
).generateQualitativeInsights(mockMetrics, []);
expect(result).toBeUndefined();
});
it('should return full qualitative data when all LLM calls succeed', async () => {
mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] });
const result = await (
dataProcessor as unknown as {
generateQualitativeInsights(
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
facets: SessionFacets[],
): Promise<
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
| undefined
>;
}
).generateQualitativeInsights(mockMetrics, mockFacets);
expect(result).toBeDefined();
expect(mockGenerateJson).toHaveBeenCalledTimes(8);
});
});
describe('generateFacets', () => {
it('should skip non-conversational sessions', async () => {
const userOnlyRecords: ChatRecord[] = [

View file

@ -388,7 +388,7 @@ export class DataProcessor {
const generate = async <T>(
promptTemplate: string,
schema: Record<string, unknown>,
): Promise<T> => {
): Promise<T | undefined> => {
const prompt = `${promptTemplate}\n\n${commonData}`;
try {
const result = await this.config.getBaseLlmClient().generateJson({
@ -400,7 +400,7 @@ export class DataProcessor {
return result as T;
} catch (error) {
logger.error('Failed to generate insight:', error);
throw error;
return undefined;
}
};

View file

@ -71,12 +71,12 @@ export interface InsightAtAGlance {
}
export interface QualitativeInsights {
impressiveWorkflows: InsightImpressiveWorkflows;
projectAreas: InsightProjectAreas;
futureOpportunities: InsightFutureOpportunities;
frictionPoints: InsightFrictionPoints;
memorableMoment: InsightMemorableMoment;
improvements: InsightImprovements;
interactionStyle: InsightInteractionStyle;
atAGlance: InsightAtAGlance;
impressiveWorkflows?: InsightImpressiveWorkflows;
projectAreas?: InsightProjectAreas;
futureOpportunities?: InsightFutureOpportunities;
frictionPoints?: InsightFrictionPoints;
memorableMoment?: InsightMemorableMoment;
improvements?: InsightImprovements;
interactionStyle?: InsightInteractionStyle;
atAGlance?: InsightAtAGlance;
}

View file

@ -72,7 +72,9 @@ describe('ShellProcessor', () => {
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
getAllowedTools: vi.fn().mockReturnValue([]),
getPermissionsAllow: vi.fn().mockReturnValue([]),
// Default: no permission manager (tests that need one set it explicitly)
getPermissionManager: vi.fn().mockReturnValue(null),
};
context = createMockCommandContext({
@ -206,9 +208,11 @@ describe('ShellProcessor', () => {
allAllowed: false,
disallowedCommands: ['rm -rf /'],
});
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(rm -rf /)',
]);
// Simulate allowedTools being pre-merged into permissionsAllow by Config,
// so PermissionManager returns 'allow' for this command.
(mockConfig.getPermissionManager as Mock).mockReturnValue({
isCommandAllowed: (_cmd: string) => 'allow',
});
mockShellExecute.mockReturnValue({
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
});

View file

@ -7,13 +7,11 @@
import {
ApprovalMode,
checkCommandPermissions,
doesToolInvocationMatch,
escapeShellArg,
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
} from '@qwen-code/qwen-code-core';
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
import type { CommandContext } from '../../ui/commands/types.js';
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
@ -109,10 +107,9 @@ export class ShellProcessor implements IPromptProcessor {
return { ...injection, resolvedCommand: undefined };
}
const resolvedCommand = command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
const resolvedCommand = command
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}}
.replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS
return { ...injection, resolvedCommand };
},
);
@ -126,15 +123,12 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
const allowedTools = config.getAllowedTools() || [];
const invocation = {
params: { command },
} as AnyToolInvocation;
const isAllowedBySettings = doesToolInvocationMatch(
'run_shell_command',
invocation,
allowedTools,
);
// Determine if this command is explicitly auto-approved via PermissionManager
const pm = config.getPermissionManager?.();
const isAllowedBySettings = pm
? pm.isCommandAllowed(command) === 'allow'
: false;
if (!allAllowed) {
if (isHardDenial) {

View file

@ -9,6 +9,11 @@ import { render } from 'ink-testing-library';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { AgentViewProvider } from './contexts/AgentViewContext.js';
import { StreamingState } from './types.js';
vi.mock('ink', async (importOriginal) => {
@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./components/agent-view/AgentTabBar.js', () => ({
AgentTabBar: () => null,
}));
describe('App', () => {
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
@ -58,13 +67,24 @@ describe('App', () => {
},
};
it('should render main content and composer when not quitting', () => {
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
const mockUIActions = {
refreshStatic: vi.fn(),
} as unknown as UIActions;
const renderWithProviders = (uiState: UIState) =>
render(
<UIActionsContext.Provider value={mockUIActions}>
<AgentViewProvider>
<UIStateContext.Provider value={uiState}>
<App />
</UIStateContext.Provider>
</AgentViewProvider>
</UIActionsContext.Provider>,
);
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Composer');
});
@ -75,11 +95,7 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={quittingUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(quittingUIState);
expect(lastFrame()).toContain('Quitting...');
});
@ -90,11 +106,7 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={dialogUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(dialogUIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('DialogManager');
@ -107,11 +119,7 @@ describe('App', () => {
ctrlCPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlCUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlCUIState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
});
@ -123,11 +131,7 @@ describe('App', () => {
ctrlDPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlDUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlDUIState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
});
@ -135,11 +139,7 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain(
'Notifications\nFooter\nMainContent\nComposer',
@ -149,11 +149,7 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent\nComposer');
});

View file

@ -78,6 +78,21 @@ vi.mock('./hooks/useAutoAcceptIndicator.js');
vi.mock('./hooks/useGitBranchName.js');
vi.mock('./contexts/VimModeContext.js');
vi.mock('./contexts/SessionContext.js');
vi.mock('./contexts/AgentViewContext.js', () => ({
useAgentViewState: vi.fn(() => ({
activeView: 'main',
agents: new Map(),
})),
useAgentViewActions: vi.fn(() => ({
switchToMain: vi.fn(),
switchToAgent: vi.fn(),
switchToNext: vi.fn(),
switchToPrevious: vi.fn(),
registerAgent: vi.fn(),
unregisterAgent: vi.fn(),
unregisterAll: vi.fn(),
})),
}));
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
@ -268,7 +283,7 @@ describe('AppContainer State Management', () => {
listSubagents: vi.fn().mockResolvedValue([]),
addChangeListener: vi.fn(),
loadSubagent: vi.fn(),
createSubagentScope: vi.fn(),
createSubagent: vi.fn(),
};
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
mockSubagentManager as SubagentManager,

View file

@ -39,6 +39,8 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
@ -52,6 +54,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
@ -97,6 +100,7 @@ import {
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@ -237,6 +241,10 @@ export const AppContainer = (props: AppContainerProps) => {
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
useCodingPlanUpdates(settings, config, historyManager.addItem);
const [isTrustDialogOpen, setTrustDialogOpen] = useState(false);
const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []);
const closeTrustDialog = useCallback(() => setTrustDialogOpen(false), []);
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
const openPermissionsDialog = useCallback(
() => setPermissionsDialogOpen(true),
@ -290,7 +298,42 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);
}
// Fire SessionStart event after config is initialized
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
const hookSystem = config.getHookSystem();
if (hookSystem) {
hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
.then(() => {
debugLogger.debug('SessionStart event completed successfully');
})
.catch((err) => {
debugLogger.warn(`SessionStart hook failed: ${err}`);
});
} else {
debugLogger.debug(
'SessionStart: HookSystem not available, skipping event',
);
}
})();
// Register SessionEnd cleanup for process exit
registerCleanup(async () => {
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
debugLogger.debug('SessionEnd event completed successfully!!!');
} catch (err) {
debugLogger.error(`SessionEnd hook failed: ${err}`);
}
});
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@ -471,6 +514,8 @@ export const AppContainer = (props: AppContainerProps) => {
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
useArenaCommand();
const {
isResumeDialogOpen,
@ -510,6 +555,8 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openTrustDialog,
openArenaDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
@ -534,8 +581,10 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
openTrustDialog,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
@ -673,12 +722,15 @@ export const AppContainer = (props: AppContainerProps) => {
// Track whether suggestions are visible for Tab key handling
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
// Auto-accept indicator
const agentViewState = useAgentViewState();
// Auto-accept indicator — disabled on agent tabs (agents handle their own)
const showAutoAcceptIndicator = useAutoAcceptIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
shouldBlockTab: () => hasSuggestionsVisible,
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
@ -691,6 +743,14 @@ export const AppContainer = (props: AppContainerProps) => {
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
if (agent) {
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
return;
}
}
if (
streamingState === StreamingState.Responding &&
isBtwCommand(submittedValue)
@ -700,7 +760,16 @@ export const AppContainer = (props: AppContainerProps) => {
}
addMessage(submittedValue);
},
[addMessage, streamingState, submitQuery],
[addMessage, agentViewState, streamingState, submitQuery],
);
const handleArenaModelsSelected = useCallback(
(models: string[]) => {
const value = models.join(',');
buffer.setText(`/arena start --models ${value} `);
closeArenaDialog();
},
[buffer, closeArenaDialog],
);
// Welcome back functionality (must be after handleFinalSubmit)
@ -776,10 +845,17 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [buffer, terminalWidth, terminalHeight]);
// Compute available terminal height based on controls measurement
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
// Compute available terminal height based on controls measurement.
// When in-process agents are present the AgentTabBar renders an extra
// row at the top of the layout; subtract it so downstream consumers
// (shell, transcript, etc.) don't overestimate available space.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
);
config.setShellExecutionConfig({
@ -1033,16 +1109,23 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager, setShowCommandMigrationNudge, config.storage],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
);
const currentCandidatesTokens = Object.values(
sessionStats.metrics?.models ?? {},
).reduce((acc, model) => acc + (model.tokens?.candidates ?? 0), 0);
const { elapsedTime, currentLoadingPhrase, taskStartTokens } =
useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
currentCandidatesTokens,
);
useAttentionNotifications({
isFocused,
streamingState,
elapsedTime,
settings,
config,
});
// Dialog close functionality
@ -1058,6 +1141,8 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
isSettingsDialogOpen,
closeSettingsDialog,
activeArenaDialog,
closeArenaDialog,
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
@ -1332,6 +1417,8 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
@ -1382,6 +1469,8 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1461,6 +1550,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
}),
[
isThemeDialogOpen,
@ -1478,6 +1569,8 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1558,6 +1651,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
],
);
@ -1577,7 +1672,11 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1626,7 +1725,11 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

@ -0,0 +1,395 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
type ArenaManager,
AgentStatus,
ArenaSessionStatus,
} from '@qwen-code/qwen-code-core';
import { arenaCommand } from './arenaCommand.js';
import type {
CommandContext,
OpenDialogActionReturn,
SlashCommand,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
function getArenaSubCommand(
name: 'start' | 'stop' | 'status' | 'select',
): SlashCommand {
const command = arenaCommand.subCommands?.find((item) => item.name === name);
if (!command?.action) {
throw new Error(`Arena subcommand "${name}" is missing an action`);
}
return command;
}
describe('arenaCommand stop subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena stop',
name: 'arena',
args: 'stop',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session is running', async () => {
const stopCommand = getArenaSubCommand('stop');
const result = await stopCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
});
});
it('opens stop dialog when a running session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
it('opens stop dialog when a completed session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
});
describe('arenaCommand status subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena status',
name: 'arena',
args: 'status',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session exists', async () => {
const statusCommand = getArenaSubCommand('status');
const result = await statusCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
});
});
it('opens status dialog when a session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
it('opens status dialog for completed session', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
});
describe('arenaCommand select subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena select',
name: 'arena',
args: 'select',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns error when no arena session exists', async () => {
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
});
it('returns error when arena is still running', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
});
});
it('returns error when all agents failed', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.FAILED,
model: { modelId: 'model-1' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
});
});
it('opens dialog when no args provided and agents have results', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-1' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-2' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_select',
});
});
it('applies changes directly when model name is provided', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' },
},
]),
applyAgentResult: vi.fn().mockResolvedValue({ success: true }),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'gpt-4o');
expect(mockManager.applyAgentResult).toHaveBeenCalledWith('agent-1');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Applied changes from gpt-4o to workspace. Arena session complete.',
});
});
it('returns error when specified model not found', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'nonexistent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No idle agent found matching "nonexistent".',
});
});
it('asks for confirmation when --discard flag is used', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(result).toEqual({
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: { raw: '/arena select' },
});
});
it('discards results after --discard confirmation', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
mockContext.overwriteConfirmed = true;
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
});
});

View file

@ -0,0 +1,659 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
ConfirmActionReturn,
MessageActionReturn,
OpenDialogActionReturn,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
ArenaManager,
ArenaEventType,
isTerminalStatus,
isSuccessStatus,
ArenaSessionStatus,
AuthType,
createDebugLogger,
stripStartupContext,
type Config,
type ArenaModelConfig,
type ArenaAgentErrorEvent,
type ArenaAgentCompleteEvent,
type ArenaAgentStartEvent,
type ArenaSessionCompleteEvent,
type ArenaSessionErrorEvent,
type ArenaSessionStartEvent,
type ArenaSessionUpdateEvent,
} from '@qwen-code/qwen-code-core';
import {
MessageType,
type ArenaAgentCardData,
type HistoryItemWithoutId,
} from '../types.js';
/**
* Parsed model entry with optional auth type.
*/
interface ParsedModel {
authType?: string;
modelId: string;
}
/**
* Parses arena command arguments.
*
* Supported formats:
* /arena start --models model1,model2 <task>
* /arena start --models authType1:model1,authType2:model2 <task>
*
* Model format: [authType:]modelId
* - "gpt-4o" uses default auth type
* - "openai:gpt-4o" uses "openai" auth type
*/
function parseArenaArgs(args: string): {
models: ParsedModel[];
task: string;
} {
const modelsMatch = args.match(/--models\s+(\S+)/);
let models: ParsedModel[] = [];
let task = args;
if (modelsMatch) {
const modelStrings = modelsMatch[1]!.split(',').filter(Boolean);
models = modelStrings.map((str) => {
// Check for authType:modelId format
const colonIndex = str.indexOf(':');
if (colonIndex > 0) {
return {
authType: str.substring(0, colonIndex),
modelId: str.substring(colonIndex + 1),
};
}
return { modelId: str };
});
task = task.replace(/--models\s+\S+/, '').trim();
}
// Strip surrounding quotes from task
task = task.replace(/^["']|["']$/g, '').trim();
return { models, task };
}
const debugLogger = createDebugLogger('ARENA_COMMAND');
interface ArenaExecutionInput {
task: string;
models: ArenaModelConfig[];
approvalMode?: string;
}
function buildArenaExecutionInput(
parsed: ReturnType<typeof parseArenaArgs>,
config: Config,
): ArenaExecutionInput | MessageActionReturn {
if (!parsed.task) {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /arena start --models model1,model2 <task>\n' +
'\n' +
'Options:\n' +
' --models [authType:]model1,[authType:]model2\n' +
' Models to compete (required, at least 2)\n' +
' Format: authType:modelId or just modelId\n' +
'\n' +
'Examples:\n' +
' /arena start --models openai:gpt-4o,anthropic:claude-3 "implement sorting"\n' +
' /arena start --models qwen-coder-plus,kimi-for-coding "fix the bug"',
};
}
if (parsed.models.length < 2) {
return {
type: 'message',
messageType: 'error',
content:
'Arena requires at least 2 models. Use --models model1,model2 to specify.\n' +
'Format: [authType:]modelId (e.g., openai:gpt-4o or just gpt-4o)',
};
}
// Get the current auth type as default for models without explicit auth type
const contentGeneratorConfig = config.getContentGeneratorConfig();
const defaultAuthType =
contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI;
// Build ArenaModelConfig for each model, resolving display names from
// the model registry when available.
const modelsConfig = config.getModelsConfig();
const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => {
const authType =
(parsedModel.authType as AuthType | undefined) ?? defaultAuthType;
const registryModels = modelsConfig.getAvailableModelsForAuthType(authType);
const resolved = registryModels.find((m) => m.id === parsedModel.modelId);
return {
modelId: parsedModel.modelId,
authType,
displayName: resolved?.label ?? parsedModel.modelId,
};
});
return {
task: parsed.task,
models,
approvalMode: config.getApprovalMode(),
};
}
/**
* Persists a single arena history item to the session JSONL file.
*
* Arena events fire asynchronously (after the slash command's recording
* window has closed), so each item must be recorded individually.
*/
function recordArenaItem(config: Config, item: HistoryItemWithoutId): void {
try {
const chatRecorder = config.getChatRecordingService();
if (!chatRecorder) return;
chatRecorder.recordSlashCommand({
phase: 'result',
rawCommand: '/arena',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
debugLogger.error('Failed to record arena history item');
}
}
function executeArenaCommand(
config: Config,
ui: CommandContext['ui'],
input: ArenaExecutionInput,
): void {
// Capture the main session's chat history so arena agents start with
// conversational context. Strip the leading startup context (env info
// user message + model ack) because each agent generates its own for
// its worktree directory — keeping the parent's would duplicate it.
let chatHistory;
try {
const fullHistory = config.getGeminiClient().getHistory();
chatHistory = stripStartupContext(fullHistory);
} catch {
debugLogger.debug('Could not retrieve chat history for arena agents');
}
const manager = new ArenaManager(config);
const emitter = manager.getEventEmitter();
const detachListeners: Array<() => void> = [];
const agentLabels = new Map<string, string>();
const addArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
ui.addItem({ type, text }, Date.now());
};
const addAndRecordArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
const item: HistoryItemWithoutId = { type, text };
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionStart = (event: ArenaSessionStartEvent) => {
const modelList = event.models
.map((model, index) => ` ${index + 1}. ${model.modelId}`)
.join('\n');
// SESSION_START fires synchronously before the first await in
// ArenaManager.start(), so the slash command processor's finally
// block already captures this item — no extra recording needed.
addArenaMessage(
MessageType.INFO,
`Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`,
);
};
const handleAgentStart = (event: ArenaAgentStartEvent) => {
agentLabels.set(event.agentId, event.model.modelId);
debugLogger.debug(
`Arena agent started: ${event.model.modelId} (${event.agentId})`,
);
};
const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => {
const attachHintPrefix = 'To view agent panes, run: ';
if (event.message.startsWith(attachHintPrefix)) {
const command = event.message.slice(attachHintPrefix.length).trim();
addAndRecordArenaMessage(
MessageType.INFO,
`Arena panes are running in tmux. Attach with: \`${command}\``,
);
return;
}
if (event.type === 'success') {
addAndRecordArenaMessage(MessageType.SUCCESS, event.message);
} else if (event.type === 'info') {
addAndRecordArenaMessage(MessageType.INFO, event.message);
} else {
addAndRecordArenaMessage(MessageType.WARNING, event.message);
}
};
const handleAgentError = (event: ArenaAgentErrorEvent) => {
const label = agentLabels.get(event.agentId) || event.agentId;
addAndRecordArenaMessage(
MessageType.ERROR,
`[${label}] failed: ${event.error}`,
);
};
const buildAgentCardData = (
result: ArenaAgentCompleteEvent['result'],
): ArenaAgentCardData => ({
label: result.model.modelId,
status: result.status,
durationMs: result.stats.durationMs,
totalTokens: result.stats.totalTokens,
inputTokens: result.stats.inputTokens,
outputTokens: result.stats.outputTokens,
toolCalls: result.stats.toolCalls,
successfulToolCalls: result.stats.successfulToolCalls,
failedToolCalls: result.stats.failedToolCalls,
rounds: result.stats.rounds,
error: result.error,
diff: result.diff,
});
const handleAgentComplete = (event: ArenaAgentCompleteEvent) => {
if (!isTerminalStatus(event.result.status)) {
return;
}
const agent = buildAgentCardData(event.result);
const item = {
type: 'arena_agent_complete',
agent,
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionError = (event: ArenaSessionErrorEvent) => {
addAndRecordArenaMessage(MessageType.ERROR, `${event.error}`);
};
const handleSessionComplete = (event: ArenaSessionCompleteEvent) => {
const item = {
type: 'arena_session_complete',
sessionStatus: event.result.status,
task: event.result.task,
totalDurationMs: event.result.totalDurationMs ?? 0,
agents: event.result.agents.map(buildAgentCardData),
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
emitter.on(ArenaEventType.SESSION_START, handleSessionStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_START, handleSessionStart),
);
emitter.on(ArenaEventType.AGENT_START, handleAgentStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_START, handleAgentStart),
);
emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate),
);
emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_ERROR, handleAgentError),
);
emitter.on(ArenaEventType.AGENT_COMPLETE, handleAgentComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_COMPLETE, handleAgentComplete),
);
emitter.on(ArenaEventType.SESSION_ERROR, handleSessionError);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_ERROR, handleSessionError),
);
emitter.on(ArenaEventType.SESSION_COMPLETE, handleSessionComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_COMPLETE, handleSessionComplete),
);
config.setArenaManager(manager);
const cols = process.stdout.columns || 120;
const rows = Math.max((process.stdout.rows || 40) - 2, 1);
const lifecycle = manager
.start({
task: input.task,
models: input.models,
cols,
rows,
approvalMode: input.approvalMode,
chatHistory,
})
.then(
() => {
debugLogger.debug('Arena agents settled');
},
(error) => {
const message = error instanceof Error ? error.message : String(error);
addAndRecordArenaMessage(MessageType.ERROR, `${message}`);
debugLogger.error('Arena session failed:', error);
// Clear the stored manager so subsequent /arena start calls
// are not blocked by the stale reference after a startup failure.
config.setArenaManager(null);
// Detach listeners on failure — session is done for good.
for (const detach of detachListeners) {
detach();
}
},
);
// NOTE: listeners are NOT detached when start() resolves because agents
// may still be alive (IDLE) and accept follow-up tasks. The listeners
// reference this manager's emitter, so they are garbage collected when
// the manager is cleaned up and replaced.
// Store so that stop can wait for start() to fully unwind before cleanup
manager.setLifecyclePromise(lifecycle);
}
export const arenaCommand: SlashCommand = {
name: 'arena',
description: 'Manage Arena sessions',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'start',
description:
'Start an Arena session with multiple models competing on the same task',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn | OpenDialogActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to start an Arena session.',
};
}
const { services, ui } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// Refuse to start if a session already exists (regardless of status)
const existingManager = config.getArenaManager();
if (existingManager) {
return {
type: 'message',
messageType: 'error',
content:
'An Arena session exists. Use /arena stop or /arena select to end it before starting a new one.',
};
}
const parsed = parseArenaArgs(args);
if (parsed.models.length === 0) {
return {
type: 'dialog',
dialog: 'arena_start',
};
}
const executionInput = buildArenaExecutionInput(parsed, config);
if ('type' in executionInput) {
return executionInput;
}
executeArenaCommand(config, ui, executionInput);
},
},
{
name: 'stop',
description: 'Stop the current Arena session',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to stop an Arena session.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
};
}
return {
type: 'dialog',
dialog: 'arena_stop',
};
},
},
{
name: 'status',
description: 'Show the current Arena session status',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
};
}
return {
type: 'dialog',
dialog: 'arena_status',
};
},
},
{
name: 'select',
altNames: ['choose'],
description:
'Select a model result and merge its diff into the current workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<
| void
| MessageActionReturn
| OpenDialogActionReturn
| ConfirmActionReturn
> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
};
}
const sessionStatus = manager.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
return {
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
};
}
// Handle --discard flag before checking for successful agents,
// so users can clean up worktrees even when all agents failed.
const trimmedArgs = args.trim();
if (trimmedArgs === '--discard') {
if (!context.overwriteConfirmed) {
return {
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: {
raw: context.invocation?.raw || '/arena select --discard',
},
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
};
}
const agents = manager.getAgentStates();
const hasSuccessful = agents.some((a) => isSuccessStatus(a.status));
if (!hasSuccessful) {
return {
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
};
}
// Handle direct model selection via args
if (trimmedArgs) {
const matchingAgent = agents.find(
(a) =>
isSuccessStatus(a.status) &&
a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase(),
);
if (!matchingAgent) {
return {
type: 'message',
messageType: 'error',
content: `No idle agent found matching "${trimmedArgs}".`,
};
}
const label = matchingAgent.model.modelId;
const result = await manager.applyAgentResult(matchingAgent.agentId);
if (!result.success) {
return {
type: 'message',
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
};
}
// No args → open the select dialog
return {
type: 'dialog',
dialog: 'arena_select',
};
},
},
],
};

View file

@ -8,6 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
import { clearCommand } from './clearCommand.js';
import { type CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
// Mock the telemetry service
vi.mock('@qwen-code/qwen-code-core', async () => {
@ -26,10 +30,19 @@ describe('clearCommand', () => {
let mockContext: CommandContext;
let mockResetChat: ReturnType<typeof vi.fn>;
let mockStartNewSession: ReturnType<typeof vi.fn>;
let mockFireSessionEndEvent: ReturnType<typeof vi.fn>;
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
let mockGetHookSystem: ReturnType<typeof vi.fn>;
beforeEach(() => {
mockResetChat = vi.fn().mockResolvedValue(undefined);
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
mockFireSessionEndEvent = vi.fn().mockResolvedValue(undefined);
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
mockGetHookSystem = vi.fn().mockReturnValue({
fireSessionEndEvent: mockFireSessionEndEvent,
fireSessionStartEvent: mockFireSessionStartEvent,
});
vi.clearAllMocks();
mockContext = createMockCommandContext({
@ -40,6 +53,12 @@ describe('clearCommand', () => {
resetChat: mockResetChat,
}) as unknown as GeminiClient,
startNewSession: mockStartNewSession,
getHookSystem: mockGetHookSystem,
getDebugLogger: () => ({
warn: vi.fn(),
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
},
},
session: {
@ -75,6 +94,50 @@ describe('clearCommand', () => {
expect(mockContext.ui.clear).toHaveBeenCalled();
});
it('should fire SessionEnd event before clearing and SessionStart event after clearing', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
await clearCommand.action(mockContext, '');
expect(mockGetHookSystem).toHaveBeenCalled();
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
SessionEndReason.Clear,
);
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
SessionStartSource.Clear,
'test-model',
);
// SessionEnd should be called before SessionStart
const sessionEndCallOrder =
mockFireSessionEndEvent.mock.invocationCallOrder[0];
const sessionStartCallOrder =
mockFireSessionStartEvent.mock.invocationCallOrder[0];
expect(sessionEndCallOrder).toBeLessThan(sessionStartCallOrder);
});
it('should handle hook errors gracefully and continue execution', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');
}
mockFireSessionEndEvent.mockRejectedValue(
new Error('SessionEnd hook failed'),
);
mockFireSessionStartEvent.mockRejectedValue(
new Error('SessionStart hook failed'),
);
await clearCommand.action(mockContext, '');
// Should still complete the clear operation despite hook errors
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
expect(mockResetChat).toHaveBeenCalledTimes(1);
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
});
it('should not attempt to reset chat if config service is not available', async () => {
if (!clearCommand.action) {
throw new Error('clearCommand must have an action.');

View file

@ -7,7 +7,13 @@
import type { SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = {
name: 'clear',
@ -20,11 +26,29 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
uiTelemetryService.reset();
// Clear loaded-skills tracking so /context doesn't show stale data
const skillTool = config
.getToolRegistry()
?.getAllTools()
.find((tool) => tool.name === ToolNames.SKILL);
if (skillTool instanceof SkillTool) {
skillTool.clearLoadedSkills();
}
if (newSessionId && context.session.startNewSession) {
context.session.startNewSession(newSessionId);
}
@ -40,6 +64,18 @@ export const clearCommand: SlashCommand = {
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}

View file

@ -0,0 +1,376 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemContextUsage,
type ContextCategoryBreakdown,
type ContextToolDetail,
type ContextMemoryDetail,
type ContextSkillDetail,
} from '../types.js';
import {
DiscoveredMCPTool,
uiTelemetryService,
getCoreSystemPrompt,
DEFAULT_TOKEN_LIMIT,
ToolNames,
SkillTool,
buildSkillLlmContent,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
/**
* Default compression token threshold (triggers compression at 70% usage).
* The autocompact buffer is (1 - threshold) * contextWindowSize.
*/
const DEFAULT_COMPRESSION_THRESHOLD = 0.7;
/**
* Estimate token count for a string using a character-based heuristic.
* ASCII chars 4 chars/token, CJK/non-ASCII chars 1.5 tokens/char.
*/
function estimateTokens(text: string): number {
if (!text || text.length === 0) return 0;
let asciiChars = 0;
let nonAsciiChars = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < 128) {
asciiChars++;
} else {
nonAsciiChars++;
}
}
// CJK and other non-ASCII characters typically produce 1.5-2 tokens each
return Math.ceil(asciiChars / 4 + nonAsciiChars * 1.5);
}
/**
* Parse concatenated memory content into individual file entries.
* Memory content format: "--- Context from: <path> ---\n<content>\n--- End of Context from: <path> ---"
*/
function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] {
if (!memoryContent || memoryContent.trim().length === 0) return [];
const results: ContextMemoryDetail[] = [];
// Use backreference (\1) to ensure start/end path markers match
const regex =
/--- Context from: (.+?) ---\n([\s\S]*?)--- End of Context from: \1 ---/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(memoryContent)) !== null) {
const filePath = match[1]!;
const content = match[2]!;
results.push({
path: filePath,
tokens: estimateTokens(content),
});
}
// If no structured markers found, treat as a single memory block
if (results.length === 0 && memoryContent.trim().length > 0) {
results.push({
path: t('memory'),
tokens: estimateTokens(memoryContent),
});
}
return results;
}
export const contextCommand: SlashCommand = {
name: 'context',
get description() {
return t(
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string) => {
const showDetails =
args?.trim().toLowerCase() === 'detail' ||
args?.trim().toLowerCase() === '-d';
const { config } = context.services;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Config not loaded.'),
},
Date.now(),
);
return;
}
// --- Gather data ---
const modelName = config.getModel() || 'unknown';
const contentGeneratorConfig = config.getContentGeneratorConfig();
const contextWindowSize =
contentGeneratorConfig.contextWindowSize ?? DEFAULT_TOKEN_LIMIT;
// Total prompt token count from API (most accurate)
const apiTotalTokens = uiTelemetryService.getLastPromptTokenCount();
// Cached content token count — when available (e.g. DashScope prefix caching),
// represents the cached overhead (system prompt + tools). Using this gives a much
// more accurate "Messages" count: promptTokens - cachedTokens = actual history tokens.
const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount();
// 1. System prompt tokens (without memory, as memory is counted separately)
const systemPromptText = getCoreSystemPrompt(undefined, modelName);
const systemPromptTokens = estimateTokens(systemPromptText);
// 2. Tool declarations tokens (includes ALL tools: built-in, MCP, skill tool)
const toolRegistry = config.getToolRegistry();
const allTools = toolRegistry ? toolRegistry.getAllTools() : [];
const toolDeclarations = toolRegistry
? toolRegistry.getFunctionDeclarations()
: [];
const toolsJsonStr = JSON.stringify(toolDeclarations);
const allToolsTokens = estimateTokens(toolsJsonStr);
// 3. Per-tool details (for breakdown display)
const builtinTools: ContextToolDetail[] = [];
const mcpTools: ContextToolDetail[] = [];
for (const tool of allTools) {
const toolJsonStr = JSON.stringify(tool.schema);
const tokens = estimateTokens(toolJsonStr);
if (tool instanceof DiscoveredMCPTool) {
mcpTools.push({
name: `${tool.serverName}__${tool.serverToolName || tool.name}`,
tokens,
});
} else if (tool.name !== ToolNames.SKILL) {
// Built-in tool (exclude SkillTool, which is shown under Skills)
builtinTools.push({
name: tool.name,
tokens,
});
}
}
// 4. Memory files
const memoryContent = config.getUserMemory();
const memoryFiles = parseMemoryFiles(memoryContent);
const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0);
// 5. Skills (progressive disclosure)
// Two cost components:
// a) Tool definition: SkillTool's description embeds all skill
// name+description listings plus instruction text — always in context.
// b) Loaded bodies: When the model invokes a skill, the full SKILL.md
// body is injected into the conversation as a tool result. We track
// which skills have been loaded and attribute their body tokens here
// so the "Skills" category accurately reflects the total cost.
const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL);
const skillToolDefinitionTokens = skillTool
? estimateTokens(JSON.stringify(skillTool.schema))
: 0;
// Determine which skills have been loaded in this session
const loadedSkillNames: ReadonlySet<string> =
skillTool instanceof SkillTool
? skillTool.getLoadedSkillNames()
: new Set();
// Per-skill breakdown: listing cost + body cost for loaded skills
const skillManager = config.getSkillManager();
const skillConfigs = skillManager ? await skillManager.listSkills() : [];
let loadedBodiesTokens = 0;
const skills: ContextSkillDetail[] = skillConfigs.map((skill) => {
const listingTokens = estimateTokens(
`<skill>\n<name>\n${skill.name}\n</name>\n<description>\n${skill.description} (${skill.level})\n</description>\n<location>\n${skill.level}\n</location>\n</skill>`,
);
const isLoaded = loadedSkillNames.has(skill.name);
let bodyTokens: number | undefined;
if (isLoaded && skill.body) {
const baseDir = skill.filePath
? skill.filePath.replace(/\/[^/]+$/, '')
: '';
bodyTokens = estimateTokens(buildSkillLlmContent(baseDir, skill.body));
loadedBodiesTokens += bodyTokens;
}
return {
name: skill.name,
tokens: listingTokens,
loaded: isLoaded,
bodyTokens,
};
});
// Total skills cost = tool definition + loaded bodies
const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens;
// 6. Autocompact buffer
const compressionThreshold =
config.getChatCompression()?.contextPercentageThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
const autocompactBuffer =
compressionThreshold > 0
? Math.round((1 - compressionThreshold) * contextWindowSize)
: 0;
// 7. Calculate raw overhead
// allToolsTokens includes the skill tool definition; loadedBodiesTokens
// covers the on-demand skill bodies now attributed to Skills.
const rawOverhead =
systemPromptTokens +
allToolsTokens +
memoryFilesTokens +
loadedBodiesTokens;
// 8. Determine total tokens and build breakdown
const isEstimated = apiTotalTokens === 0;
// Sum of MCP tool tokens for category-level display
const mcpToolsTotalTokens = mcpTools.reduce(
(sum, tool) => sum + tool.tokens,
0,
);
let totalTokens: number;
let displaySystemPrompt: number;
let displayBuiltinTools: number;
let displayMcpTools: number;
let displayMemoryFiles: number;
let displaySkills: number;
let messagesTokens: number;
let freeSpace: number;
let detailBuiltinTools: ContextToolDetail[];
let detailMcpTools: ContextToolDetail[];
let detailMemoryFiles: ContextMemoryDetail[];
let detailSkills: ContextSkillDetail[];
if (isEstimated) {
// No API data yet: show raw overhead estimates only.
// Use 0 as totalTokens so the progress bar stays empty —
// avoids showing an inflated estimate that would "decrease"
// once real API data arrives.
totalTokens = 0;
displaySystemPrompt = systemPromptTokens;
// Skills = tool definition + loaded bodies
displaySkills = skillsTokens;
// builtinTools = allTools minus skills-definition minus mcpTools
displayBuiltinTools = Math.max(
0,
allToolsTokens - skillToolDefinitionTokens - mcpToolsTotalTokens,
);
displayMcpTools = mcpToolsTotalTokens;
displayMemoryFiles = memoryFilesTokens;
messagesTokens = 0;
// Free space accounts for the estimated overhead
freeSpace = Math.max(
0,
contextWindowSize - rawOverhead - autocompactBuffer,
);
detailBuiltinTools = builtinTools;
detailMcpTools = mcpTools;
detailMemoryFiles = memoryFiles;
detailSkills = skills;
} else {
// API data available: use actual total with proportional scaling
totalTokens = apiTotalTokens;
// When estimates overshoot API total, scale down proportionally
// so the breakdown categories add up to totalTokens.
const overheadScale =
rawOverhead > totalTokens ? totalTokens / rawOverhead : 1;
displaySystemPrompt = Math.round(systemPromptTokens * overheadScale);
const scaledAllTools = Math.round(allToolsTokens * overheadScale);
displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale);
// Skills = tool definition + loaded bodies (scaled together)
displaySkills = Math.round(skillsTokens * overheadScale);
const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale);
displayMcpTools = scaledMcpTotal;
// builtinTools = allTools minus skill-definition minus mcpTools
const scaledSkillDefinition = Math.round(
skillToolDefinitionTokens * overheadScale,
);
displayBuiltinTools = Math.max(
0,
scaledAllTools - scaledSkillDefinition - scaledMcpTotal,
);
const scaledOverhead =
displaySystemPrompt +
scaledAllTools +
displayMemoryFiles +
Math.round(loadedBodiesTokens * overheadScale);
// When the API reports cached content tokens (e.g. DashScope prefix caching),
// use them as the actual overhead indicator for a more accurate messages count.
// cachedTokens ≈ system prompt + tools tokens actually served from cache.
// This avoids the "messages = 0" problem caused by estimation overshoot.
if (apiCachedTokens > 0) {
messagesTokens = Math.max(0, totalTokens - apiCachedTokens);
} else {
messagesTokens = Math.max(0, totalTokens - scaledOverhead);
}
freeSpace = Math.max(
0,
contextWindowSize - totalTokens - autocompactBuffer,
);
// Scale detail items to match their parent categories
const scaleDetail = <T extends { tokens: number }>(items: T[]): T[] =>
overheadScale < 1
? items.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
}))
: items;
detailBuiltinTools = scaleDetail(builtinTools);
detailMcpTools = scaleDetail(mcpTools);
detailMemoryFiles = scaleDetail(memoryFiles);
detailSkills =
overheadScale < 1
? skills.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
bodyTokens: item.bodyTokens
? Math.round(item.bodyTokens * overheadScale)
: undefined,
}))
: skills;
}
const breakdown: ContextCategoryBreakdown = {
systemPrompt: displaySystemPrompt,
builtinTools: displayBuiltinTools,
mcpTools: displayMcpTools,
memoryFiles: displayMemoryFiles,
skills: displaySkills,
messages: messagesTokens,
freeSpace,
autocompactBuffer,
};
const contextUsageItem: HistoryItemContextUsage = {
type: MessageType.CONTEXT_USAGE,
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools: detailBuiltinTools,
mcpTools: detailMcpTools,
memoryFiles: detailMemoryFiles,
skills: detailSkills,
isEstimated,
showDetails,
};
context.ui.addItem(contextUsageItem, Date.now());
},
};

View file

@ -7,6 +7,7 @@
import type { SlashCommand, CommandContext } from './types.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
@ -25,6 +26,44 @@ export function expandHomeDir(p: string): string {
return path.normalize(expandedPath);
}
/**
* Returns directory path completions for the given partial argument.
* Supports comma-separated paths by completing only the last segment.
*/
export function getDirPathCompletions(partialArg: string): string[] {
const lastComma = partialArg.lastIndexOf(',');
const prefix = lastComma >= 0 ? partialArg.substring(0, lastComma + 1) : '';
const partial =
lastComma >= 0
? partialArg.substring(lastComma + 1).trimStart()
: partialArg;
const trimmed = partial.trim();
if (!trimmed) return [];
const expanded = trimmed.startsWith('~')
? trimmed.replace(/^~/, os.homedir())
: trimmed;
const endsWithSep = expanded.endsWith('/') || expanded.endsWith(path.sep);
const searchDir = endsWithSep ? expanded : path.dirname(expanded);
const namePrefix = endsWithSep ? '' : path.basename(expanded);
try {
return fs
.readdirSync(searchDir, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
e.name.startsWith(namePrefix) &&
!e.name.startsWith('.'),
)
.map((e) => prefix + path.join(searchDir, e.name))
.slice(0, 8);
} catch {
return [];
}
}
export const directoryCommand: SlashCommand = {
name: 'directory',
altNames: ['dir'],
@ -41,6 +80,8 @@ export const directoryCommand: SlashCommand = {
);
},
kind: CommandKind.BUILT_IN,
completion: async (_context: CommandContext, partialArg: string) =>
getDirPathCompletions(partialArg),
action: async (context: CommandContext, args: string) => {
const {
ui: { addItem },

View file

@ -22,6 +22,7 @@ import {
toJsonl,
generateExportFilename,
} from '../utils/export/index.js';
import { t } from '../../i18n/index.js';
/**
* Action for the 'md' subcommand - exports session to markdown.
@ -320,30 +321,40 @@ async function exportJsonlAction(
*/
export const exportCommand: SlashCommand = {
name: 'export',
description: 'Export current session message history to a file',
get description() {
return t('Export current session message history to a file');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'html',
description: 'Export session to HTML format',
get description() {
return t('Export session to HTML format');
},
kind: CommandKind.BUILT_IN,
action: exportHtmlAction,
},
{
name: 'md',
description: 'Export session to markdown format',
get description() {
return t('Export session to markdown format');
},
kind: CommandKind.BUILT_IN,
action: exportMarkdownAction,
},
{
name: 'json',
description: 'Export session to JSON format',
get description() {
return t('Export session to JSON format');
},
kind: CommandKind.BUILT_IN,
action: exportJsonAction,
},
{
name: 'jsonl',
description: 'Export session to JSONL format (one message per line)',
get description() {
return t('Export session to JSONL format (one message per line)');
},
kind: CommandKind.BUILT_IN,
action: exportJsonlAction,
},

View file

@ -18,7 +18,7 @@ describe('permissionsCommand', () => {
it('should have the correct name and description', () => {
expect(permissionsCommand.name).toBe('permissions');
expect(permissionsCommand.description).toBe('Manage folder trust settings');
expect(permissionsCommand.description).toBe('Manage permission rules');
});
it('should be a built-in command', () => {

View file

@ -11,7 +11,7 @@ import { t } from '../../i18n/index.js';
export const permissionsCommand: SlashCommand = {
name: 'permissions',
get description() {
return t('Manage folder trust settings');
return t('Manage permission rules');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({

View file

@ -13,6 +13,7 @@ import {
CommandKind,
} from './types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
async function restoreAction(
context: CommandContext,
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
return {
name: 'restore',
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
get description() {
return t(
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
);
},
kind: CommandKind.BUILT_IN,
action: restoreAction,
completion,

View file

@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { trustCommand } from './trustCommand.js';
import { type CommandContext, CommandKind } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('trustCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
mockContext = createMockCommandContext();
});
it('should have the correct name and description', () => {
expect(trustCommand.name).toBe('trust');
expect(trustCommand.description).toBe('Manage folder trust settings');
});
it('should be a built-in command', () => {
expect(trustCommand.kind).toBe(CommandKind.BUILT_IN);
});
it('should return an action to open the trust dialog', () => {
const actionResult = trustCommand.action?.(mockContext, '');
expect(actionResult).toEqual({
type: 'dialog',
dialog: 'trust',
});
});
});

View file

@ -0,0 +1,21 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
export const trustCommand: SlashCommand = {
name: 'trust',
get description() {
return t('Manage folder trust settings');
},
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({
type: 'dialog',
dialog: 'trust',
}),
};

View file

@ -148,6 +148,10 @@ export interface OpenDialogActionReturn {
dialog:
| 'help'
| 'arena_start'
| 'arena_select'
| 'arena_stop'
| 'arena_status'
| 'auth'
| 'theme'
| 'editor'
@ -155,6 +159,7 @@ export interface OpenDialogActionReturn {
| 'model'
| 'subagent_create'
| 'subagent_list'
| 'trust'
| 'permissions'
| 'approval-mode'
| 'resume'

View file

@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview BaseTextInput shared text input component with rendering
* and common readline keyboard handling.
*
* Provides:
* - Viewport line rendering from a TextBuffer with cursor display
* - Placeholder support when buffer is empty
* - Configurable border/prefix styling
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
* - An `onKeypress` interceptor so consumers can layer custom behavior
*
* Used by both InputPrompt (with syntax highlighting + complex key handling)
* and AgentComposer (with minimal customization).
*/
import type React from 'react';
import { useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import type { TextBuffer } from './shared/text-buffer.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
// ─── Types ──────────────────────────────────────────────────
export interface RenderLineOptions {
/** The text content of this visual line. */
lineText: string;
/** Whether the cursor is on this visual line. */
isOnCursorLine: boolean;
/** The cursor column within this visual line (visual col, not logical). */
cursorCol: number;
/** Whether the cursor should be rendered. */
showCursor: boolean;
/** Index of this line within the rendered viewport (0-based). */
visualLineIndex: number;
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
absoluteVisualIndex: number;
/** The underlying text buffer. */
buffer: TextBuffer;
/** The first visible visual row (scroll offset). */
scrollVisualRow: number;
}
export interface BaseTextInputProps {
/** The text buffer driving this input. */
buffer: TextBuffer;
/** Called when the user submits (Enter). Buffer is cleared automatically. */
onSubmit: (text: string) => void;
/**
* Optional key interceptor. Called before default readline handling.
* Return `true` if the key was handled (skips default processing).
*/
onKeypress?: (key: Key) => boolean;
/** Whether to show the blinking block cursor. Defaults to true. */
showCursor?: boolean;
/** Placeholder text shown when the buffer is empty. */
placeholder?: string;
/** Custom prefix node (defaults to `> `). */
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;
/** Whether keyboard handling is active. Defaults to true. */
isActive?: boolean;
/**
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
* When not provided, lines are rendered as plain text with cursor overlay.
*/
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
}
// ─── Default line renderer ──────────────────────────────────
/**
* Renders a single visual line with an inverse-video block cursor.
* Uses codepoint-aware string operations for Unicode/emoji safety.
*/
export function defaultRenderLine({
lineText,
isOnCursorLine,
cursorCol,
showCursor,
}: RenderLineOptions): React.ReactNode {
if (!isOnCursorLine || !showCursor) {
return <Text>{lineText || ' '}</Text>;
}
const len = cpLen(lineText);
// Cursor past end of line — append inverse space
if (cursorCol >= len) {
return (
<Text>
{lineText}
{chalk.inverse(' ') + '\u200B'}
</Text>
);
}
const before = cpSlice(lineText, 0, cursorCol);
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
const after = cpSlice(lineText, cursorCol + 1);
return (
<Text>
{before}
{chalk.inverse(cursorChar)}
{after}
</Text>
);
}
// ─── Component ──────────────────────────────────────────────
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
buffer,
onSubmit,
onKeypress,
showCursor = true,
placeholder,
prefix,
borderColor,
isActive = true,
renderLine = defaultRenderLine,
}) => {
// ── Keyboard handling ──
const handleKey = useCallback(
(key: Key) => {
// Let the consumer intercept first
if (onKeypress?.(key)) {
return;
}
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const text = buffer.text;
buffer.setText('');
onSubmit(text);
}
return;
}
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Escape → clear input
if (keyMatchers[Command.ESCAPE](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+C → clear input
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+A → home
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
// Ctrl+E → end
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+K → kill to end of line
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
// Ctrl+U → kill to start of line
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// Ctrl+W / Alt+Backspace → delete word backward
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// Ctrl+X Ctrl+E → open in external editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Backspace
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
) {
buffer.backspace();
return;
}
// Fallthrough — delegate to buffer's built-in input handler
buffer.handleInput(key);
},
[buffer, onSubmit, onKeypress],
);
useKeypress(handleKey, { isActive });
// ── Rendering ──
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const resolvedBorderColor = borderColor ?? theme.border.focused;
const resolvedPrefix = prefix ?? (
<Text color={theme.text.accent}>{'> '}</Text>
);
return (
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
</Box>
</Box>
);
};

View file

@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
debugMessage: '',
nightly: false,
isTrustedFolder: true,
taskStartTokens: 0,
...overrides,
}) as UIState;

View file

@ -27,7 +27,17 @@ export const Composer = () => {
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState;
const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce(
(acc, model) => ({
prompt: acc.prompt + (model.tokens?.prompt ?? 0),
candidates: acc.candidates + (model.tokens?.candidates ?? 0),
}),
{ prompt: 0, candidates: 0 },
);
const taskTokens = tokens.candidates - taskStartTokens;
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
@ -64,6 +74,7 @@ export const Composer = () => {
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
candidatesTokens={taskTokens}
/>
)}
@ -104,8 +115,8 @@ export const Composer = () => {
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
{!showSuggestions &&
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
{uiState.isInputActive &&
!showSuggestions &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (

View file

@ -18,8 +18,13 @@ import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { TrustDialog } from './TrustDialog.js';
import { PermissionsDialog } from './PermissionsDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
@ -237,6 +242,49 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.activeArenaDialog === 'start') {
return (
<ArenaStartDialog
onClose={() => uiActions.closeArenaDialog()}
onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)}
/>
);
}
if (uiState.activeArenaDialog === 'status') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaStatusDialog
manager={arenaManager}
closeArenaDialog={uiActions.closeArenaDialog}
width={mainAreaWidth}
/>
);
}
}
if (uiState.activeArenaDialog === 'stop') {
return (
<ArenaStopDialog
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
if (uiState.activeArenaDialog === 'select') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaSelectDialog
manager={arenaManager}
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">
@ -267,15 +315,16 @@ export const DialogManager = ({
);
}
}
if (uiState.isPermissionsDialogOpen) {
if (uiState.isTrustDialogOpen) {
return (
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
/>
<TrustDialog onExit={uiActions.closeTrustDialog} addItem={addItem} />
);
}
if (uiState.isPermissionsDialogOpen) {
return <PermissionsDialog onExit={uiActions.closePermissionsDialog} />;
}
if (uiState.isSubagentCreateDialogOpen) {
return (
<AgentCreationWizard

View file

@ -24,6 +24,7 @@ import {
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
SuccessMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
@ -38,6 +39,8 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
import { BtwMessage } from './messages/BtwMessage.js';
@ -133,6 +136,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'success' && (
<SuccessMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} />
)}
@ -192,6 +198,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'context_usage' && (
<ContextUsage
modelName={itemForDisplay.modelName}
totalTokens={itemForDisplay.totalTokens}
contextWindowSize={itemForDisplay.contextWindowSize}
breakdown={itemForDisplay.breakdown}
builtinTools={itemForDisplay.builtinTools}
mcpTools={itemForDisplay.mcpTools}
memoryFiles={itemForDisplay.memoryFiles}
skills={itemForDisplay.skills}
isEstimated={itemForDisplay.isEstimated}
showDetails={itemForDisplay.showDetails}
/>
)}
{itemForDisplay.type === 'arena_agent_complete' && (
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
)}
{itemForDisplay.type === 'arena_session_complete' && (
<ArenaSessionCard
sessionStatus={itemForDisplay.sessionStatus}
task={itemForDisplay.task}
totalDurationMs={itemForDisplay.totalDurationMs}
agents={itemForDisplay.agents}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} />
)}

View file

@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
});
describe('command search (Ctrl+R when not in shell)', () => {
it('passes newest-first user history to command search', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
const commandSearchCall =
mockedUseReverseSearchCompletion.mock.calls.find(
([, history]) =>
Array.isArray(history) &&
history.length === 3 &&
history.includes('newest'),
);
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
unmount();
});
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
useAgentViewState,
useAgentViewActions,
} from '../contexts/AgentViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';
/**
* Represents an attachment (e.g., pasted image) displayed above the input prompt
@ -78,30 +83,8 @@ export interface InputPromptProps {
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
// Re-export from shared utils for backwards compatibility
export { calculatePromptWidths } from '../utils/layoutUtils.js';
// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const uiState = useUIState();
const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
const hasAgents = agents.size > 0;
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -213,9 +199,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const commandSearchHistory = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchHistory,
commandSearchActive,
);
@ -225,7 +216,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@ -351,6 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
// When an arena session starts (agents appear), reset history position so
// that pressing down-arrow immediately focuses the agent tab bar instead
// of cycling through input history.
const prevHasAgentsRef = useRef(hasAgents);
useEffect(() => {
if (hasAgents && !prevHasAgentsRef.current) {
inputHistory.resetHistoryNav();
}
prevHasAgentsRef.current = hasAgents;
}, [hasAgents, inputHistory]);
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
@ -411,13 +414,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}, []);
const handleInput = useCallback(
(key: Key) => {
(key: Key): boolean => {
// When the tab bar has focus, block all non-printable keys so arrow
// keys and shortcuts don't interfere. Printable characters fall
// through to BaseTextInput's default handler so the first keystroke
// appears in the input immediately (the tab bar handler releases
// focus on the same event).
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
return true;
}
if (key.paste) {
@ -459,18 +479,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Normal paste handling for small content
buffer.handleInput(key);
}
return;
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
return true;
}
// 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;
return true;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
@ -496,7 +516,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
return true;
}
// Toggle keyboard shortcuts display with "?" when buffer is empty
@ -507,7 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts
) {
onToggleShortcuts();
return;
return true;
}
// Hide shortcuts on any other key press
@ -537,33 +557,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
return true;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
return true;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
return true;
}
setEscPressCount(1);
setShowEscapePrompt(true);
@ -579,7 +599,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState();
resetEscapeState();
}
return;
return true;
}
// Ctrl+Y: Retry the last failed request.
@ -589,19 +609,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
return true;
}
if (reverseSearchActive || commandSearchActive) {
@ -626,29 +646,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
return true;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
return true;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
return true;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
return true;
}
}
@ -660,7 +680,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(textToSubmit);
resetState();
setActive(false);
return;
return true;
}
// Prevent up/down from falling through to regular history navigation
@ -668,14 +688,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
return true;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
return true;
}
if (completion.showSuggestions) {
@ -683,12 +703,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
}
@ -703,7 +723,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
return true;
}
}
@ -711,28 +731,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (isAttachmentMode && attachments.length > 0) {
if (key.name === 'left') {
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
return;
return true;
}
if (key.name === 'right') {
setSelectedAttachmentIndex((i) =>
Math.min(attachments.length - 1, i + 1),
);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
// Exit attachment mode and return to input
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
if (key.name === 'backspace' || key.name === 'delete') {
handleAttachmentDelete(selectedAttachmentIndex);
return;
return true;
}
if (key.name === 'return' || key.name === 'escape') {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
// For other keys, exit attachment mode and let input handle them
setIsAttachmentMode(false);
@ -753,7 +773,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setIsAttachmentMode(true);
setSelectedAttachmentIndex(attachments.length - 1);
return;
return true;
}
if (!shellModeActive) {
@ -761,16 +781,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
return true;
}
// Handle arrow-up/down for history on single-line or at edges
if (
@ -779,27 +799,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
if (inputHistory.navigateDown()) {
return true;
}
if (hasAgents) {
setAgentTabBarFocused(true);
return true;
}
return true;
}
} else {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
return true;
}
}
@ -810,7 +836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// paste markers may not work reliably and Enter key events can leak from pasted text.
if (pasteWorkaround && recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
return true;
}
const [row, col] = buffer.cursor;
@ -823,65 +849,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(buffer.text);
}
}
return;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
return true;
}
// Ctrl+V for clipboard image paste
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
return true;
}
// Handle backspace with placeholder-aware deletion
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
pendingPastes.size > 0 &&
(key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h'))
) {
const text = buffer.text;
const [row, col] = buffer.cursor;
@ -894,7 +876,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
offset += col;
// Check if we're at the end of any placeholder
let placeholderDeleted = false;
for (const placeholder of pendingPastes.keys()) {
const placeholderStart = offset - placeholder.length;
if (
@ -913,20 +894,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (parsed) {
freePlaceholderId(parsed.charCount, parsed.id);
}
placeholderDeleted = true;
break;
return true;
}
}
if (!placeholderDeleted) {
// Normal backspace behavior
buffer.backspace();
}
return;
// No placeholder matched — fall through to BaseTextInput's default backspace
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Ctrl+C with completion active — also reset completion state
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
resetCompletionState();
}
// Fall through to BaseTextInput's default CLEAR_INPUT handler
}
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
return false;
},
[
focus,
@ -964,15 +947,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pendingPastes,
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
hasAgents,
setAgentTabBarFocused,
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const renderLineWithHighlighting = useCallback(
(opts: RenderLineOptions): React.ReactNode => {
const {
lineText,
isOnCursorLine,
cursorCol: cursorVisualColAbsolute,
showCursor: showCursorOpt,
absoluteVisualIndex,
buffer: buf,
} = opts;
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buf.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
const renderedLine: React.ReactNode[] = [];
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const segStart = charCount;
const segEnd = segStart + segLen;
if (
cursorVisualColAbsolute >= segStart &&
cursorVisualColAbsolute < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
cursorVisualColAbsolute - segStart,
cursorVisualColAbsolute - segStart + 1,
);
const highlighted = showCursorOpt
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
highlighted +
cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return <Text>{renderedLine}</Text>;
},
[],
);
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
@ -1009,10 +1066,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
const prefixNode = (
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
);
return (
<>
{attachments.length > 0 && (
@ -1032,142 +1112,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
))}
</Box>
)}
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmitAndClear}
onKeypress={handleInput}
showCursor={showCursor}
placeholder={placeholder}
prefix={prefixNode}
borderColor={borderColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, visualIdxInRenderedSet) => {
const absoluteVisualIdx =
scrollVisualRow + visualIdxInRenderedSet;
const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx];
const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow;
const isOnCursorLine =
focus && visualIdxInRenderedSet === cursorVisualRow;
const renderedLine: React.ReactNode[] = [];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buffer.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(
logicalLine,
logicalLineIdx,
);
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const relativeVisualColForHighlight = cursorVisualColAbsolute;
const segStart = charCount;
const segEnd = segStart + segLen;
if (
relativeVisualColForHighlight >= segStart &&
relativeVisualColForHighlight < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
relativeVisualColForHighlight - segStart,
relativeVisualColForHighlight - segStart + 1,
);
const highlighted = showCursor
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(
seg.text,
0,
relativeVisualColForHighlight - segStart,
) +
highlighted +
cpSlice(
seg.text,
relativeVisualColForHighlight - segStart + 1,
);
}
charCount = segEnd;
}
const color =
seg.type === 'command' || seg.type === 'file'
? theme.text.accent
: theme.text.primary;
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>
{shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
<SuggestionsDisplay

View file

@ -72,7 +72,8 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
@ -88,7 +89,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
expect(output).not.toContain('10s');
});
it('should display the currentLoadingPhrase correctly', () => {
@ -112,7 +113,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('(1m · esc to cancel)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
@ -124,7 +125,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
expect(lastFrame()).toContain('(2m 5s · esc to cancel)');
});
it('should render rightContent when provided', () => {
@ -155,7 +156,7 @@ describe('<LoadingIndicator />', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
expect(output).toContain('(2s · esc to cancel)');
// Transition to WaitingForConfirmation
rerender(
@ -170,7 +171,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
expect(output).not.toContain('15s');
// Transition back to Idle
rerender(
@ -262,7 +263,7 @@ describe('<LoadingIndicator />', () => {
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('(5s · esc to cancel)');
expect(output).toContain('Right');
});
@ -284,8 +285,8 @@ describe('<LoadingIndicator />', () => {
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[0]).not.toContain('5s');
expect(lines[1]).toContain('5s');
expect(lines[2]).toContain('Right');
}
});
@ -308,4 +309,70 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
describe('token display', () => {
it('should display output tokens inline with arrow notation', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={847} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('↓ 847 tokens');
expect(output).not.toContain('↑');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should not display tokens when output tokens is 0', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={0} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should not display tokens when props are undefined', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should hide tokens in narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={500} />,
StreamingState.Responding,
79,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
expect(output).toContain('esc to cancel');
});
it('should show tokens in wide terminal with inline format', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
80,
);
const output = lastFrame();
expect(output).toContain('↓ 5.4k tokens');
});
it('should format tokens inline with time and cancel', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
});
});

View file

@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
@ -21,6 +21,7 @@ interface LoadingIndicatorProps {
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
candidatesTokens?: number;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
elapsedTime,
rightContent,
thought,
candidatesTokens,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@ -39,18 +41,26 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const outputTokens = candidatesTokens ?? 0;
const showTokens = !isNarrow && outputTokens > 0;
const timeStr =
elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000);
const tokenStr = showTokens
? ` · ↓ ${formatTokenCount(outputTokens)} tokens`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
})
: null;
return (
<Box paddingLeft={0} flexDirection="column">
<Box paddingLeft={2} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"

View file

@ -0,0 +1,986 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as nodePath from 'node:path';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { SettingScope } from '../../config/settings.js';
import { TextInput } from './shared/TextInput.js';
import { Colors } from '../colors.js';
import { t } from '../../i18n/index.js';
import type {
PermissionManager,
RuleWithSource,
RuleType,
} from '@qwen-code/qwen-code-core';
import { isPathWithinRoot } from '@qwen-code/qwen-code-core';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type TabId = 'allow' | 'ask' | 'deny' | 'workspace';
interface Tab {
id: TabId;
label: string;
description: string;
}
/** Internal views for the dialog state machine. */
type DialogView =
| 'rule-list' // main rule list view
| 'add-rule-input' // text input for new rule
| 'add-rule-scope' // scope selector after entering a rule
| 'delete-confirm' // confirm rule deletion
| 'ws-dir-list' // workspace directory list
| 'ws-add-dir-input' // text input for adding a directory
| 'ws-remove-confirm'; // confirm directory removal
// ---------------------------------------------------------------------------
// Scope items (matches Claude Code screenshot layout)
// ---------------------------------------------------------------------------
interface PermScopeItem {
label: string;
description: string;
value: SettingScope;
key: string;
}
function getPermScopeItems(): PermScopeItem[] {
return [
{
label: t('Project settings'),
description: t('Checked in at .qwen/settings.json'),
value: SettingScope.Workspace,
key: 'project',
},
{
label: t('User settings'),
description: t('Saved in at ~/.qwen/settings.json'),
value: SettingScope.User,
key: 'user',
},
];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getTabs(): Tab[] {
return [
{
id: 'allow',
label: t('Allow'),
description: t("Qwen Code won't ask before using allowed tools."),
},
{
id: 'ask',
label: t('Ask'),
description: t('Qwen Code will ask before using these tools.'),
},
{
id: 'deny',
label: t('Deny'),
description: t('Qwen Code is not allowed to use denied tools.'),
},
{
id: 'workspace',
label: t('Workspace'),
description: t('Manage trusted directories for this workspace.'),
},
];
}
function describeRule(raw: string): string {
const match = raw.match(/^([^(]+?)(?:\((.+)\))?$/);
if (!match) return raw;
const toolName = match[1]!.trim();
const specifier = match[2]?.trim();
if (!specifier) {
return t('Any use of the {{tool}} tool', { tool: toolName });
}
return t("{{tool}} commands matching '{{pattern}}'", {
tool: toolName,
pattern: specifier,
});
}
function scopeLabel(scope: string): string {
switch (scope) {
case 'user':
return t('From user settings');
case 'workspace':
return t('From project settings');
case 'session':
return t('From session');
default:
return scope;
}
}
// ---------------------------------------------------------------------------
// Component props
// ---------------------------------------------------------------------------
interface PermissionsDialogProps {
onExit: () => void;
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function PermissionsDialog({
onExit,
}: PermissionsDialogProps): React.JSX.Element {
const config = useConfig();
const settings = useSettings();
const pm = config.getPermissionManager?.() as PermissionManager | null;
// --- Tab state ---
const tabs = useMemo(() => getTabs(), []);
const [activeTabIndex, setActiveTabIndex] = useState(0);
const activeTab = tabs[activeTabIndex]!;
// --- Rule list state ---
const [allRules, setAllRules] = useState<RuleWithSource[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [isSearchActive, setIsSearchActive] = useState(false);
// --- Dialog view state machine ---
const [view, setView] = useState<DialogView>('rule-list');
const [newRuleInput, setNewRuleInput] = useState('');
const [pendingRuleText, setPendingRuleText] = useState('');
const [deleteTarget, setDeleteTarget] = useState<RuleWithSource | null>(null);
// --- Workspace directory state ---
const workspaceContext = config.getWorkspaceContext();
const [newDirInput, setNewDirInput] = useState('');
const [dirInputError, setDirInputError] = useState('');
const [dirInputRemountKey, setDirInputRemountKey] = useState(0);
const [completionIndex, setCompletionIndex] = useState(0);
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
const [dirRefreshKey, setDirRefreshKey] = useState(0);
// Refresh rules from PermissionManager
const refreshRules = useCallback(() => {
if (pm) {
setAllRules(pm.listRules());
}
}, [pm]);
useEffect(() => {
refreshRules();
}, [refreshRules]);
// --- Workspace directory helpers ---
const directories = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
dirRefreshKey; // dependency to trigger re-computation
return workspaceContext.getDirectories();
}, [workspaceContext, dirRefreshKey]);
const initialDirs = useMemo(
() => new Set(workspaceContext.getInitialDirectories()),
[workspaceContext],
);
// Filesystem completions based on current input
const dirCompletions = useMemo(() => {
const trimmed = newDirInput.trim();
if (!trimmed) return [];
const expanded = trimmed.startsWith('~')
? trimmed.replace(/^~/, os.homedir())
: trimmed;
const endsWithSep =
expanded.endsWith('/') || expanded.endsWith(nodePath.sep);
const searchDir = endsWithSep ? expanded : nodePath.dirname(expanded);
const prefix = endsWithSep ? '' : nodePath.basename(expanded);
try {
return fs
.readdirSync(searchDir, { withFileTypes: true })
.filter(
(e) =>
e.isDirectory() &&
e.name.startsWith(prefix) &&
!e.name.startsWith('.'),
)
.map((e) => nodePath.join(searchDir, e.name))
.slice(0, 6);
} catch {
return [];
}
}, [newDirInput]);
const handleDirInputChange = useCallback(
(text: string) => {
setNewDirInput(text);
if (dirInputError) setDirInputError('');
},
[dirInputError],
);
// Reset selection to first item whenever the completions list changes
useEffect(() => {
setCompletionIndex(0);
}, [dirCompletions]);
const handleDirTabComplete = useCallback(() => {
const selected = dirCompletions[completionIndex] ?? dirCompletions[0];
if (selected) {
setNewDirInput(selected + '/');
setDirInputRemountKey((k) => k + 1);
}
}, [dirCompletions, completionIndex]);
const handleDirCompletionUp = useCallback(() => {
if (dirCompletions.length === 0) return;
setCompletionIndex(
(prev) => (prev - 1 + dirCompletions.length) % dirCompletions.length,
);
}, [dirCompletions.length]);
const handleDirCompletionDown = useCallback(() => {
if (dirCompletions.length === 0) return;
setCompletionIndex((prev) => (prev + 1) % dirCompletions.length);
}, [dirCompletions.length]);
const dirListItems = useMemo(() => {
const items: Array<{
label: string;
value: string;
key: string;
}> = [];
// 'Add directory…' always FIRST
items.push({
label: t('Add directory…'),
value: '__add_dir__',
key: '__add_dir__',
});
// Only show non-initial (runtime-added) directories in the selectable list
for (const dir of directories) {
if (!initialDirs.has(dir)) {
items.push({
label: dir,
value: dir,
key: `dir-${dir}`,
});
}
}
return items;
}, [directories, initialDirs]);
const handleDirListSelect = useCallback(
(value: string) => {
if (value === '__add_dir__') {
setNewDirInput('');
setView('ws-add-dir-input');
return;
}
// Selecting a directory → offer to remove if not initial
if (!initialDirs.has(value)) {
setRemoveDirTarget(value);
setView('ws-remove-confirm');
}
},
[initialDirs],
);
const handleAddDirSubmit = useCallback(() => {
const trimmed = newDirInput.trim();
if (!trimmed) return;
const expanded = trimmed.startsWith('~')
? trimmed.replace(/^~/, os.homedir())
: trimmed;
const absoluteExpanded = nodePath.isAbsolute(expanded)
? expanded
: nodePath.resolve(expanded);
// Existence & type checks
if (!fs.existsSync(absoluteExpanded)) {
setDirInputError(t('Directory does not exist.'));
return;
}
if (!fs.statSync(absoluteExpanded).isDirectory()) {
setDirInputError(t('Path is not a directory.'));
return;
}
// Resolve real path to match what workspaceContext stores
let resolved: string;
try {
resolved = fs.realpathSync(absoluteExpanded);
} catch {
resolved = absoluteExpanded;
}
// Validate: exact duplicate
if ((directories as string[]).includes(resolved)) {
setDirInputError(t('This directory is already in the workspace.'));
return;
}
// Validate: is a subdirectory of an existing workspace directory
for (const existingDir of directories) {
if (isPathWithinRoot(resolved, existingDir)) {
setDirInputError(
t('Already covered by existing directory: {{dir}}', {
dir: existingDir,
}),
);
return;
}
}
setDirInputError('');
// Add to workspace context (already validated)
workspaceContext.addDirectory(resolved);
// Persist directly to project (Workspace) settings
const key = 'context.includeDirectories';
const currentDirs = (settings.merged as Record<string, unknown>)[
'context'
] as Record<string, string[]> | undefined;
const existingDirs = currentDirs?.['includeDirectories'] ?? [];
if (!existingDirs.includes(resolved)) {
settings.setValue(SettingScope.Workspace, key, [
...existingDirs,
resolved,
]);
}
setDirRefreshKey((k) => k + 1);
setView('ws-dir-list');
setNewDirInput('');
}, [newDirInput, directories, workspaceContext, settings]);
const handleRemoveDirConfirm = useCallback(() => {
if (!removeDirTarget) return;
// Remove from workspace context
workspaceContext.removeDirectory(removeDirTarget);
// Remove from settings (try both scopes)
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
const contextSection = (scopeSettings as Record<string, unknown>)[
'context'
] as Record<string, string[]> | undefined;
const scopeDirs = contextSection?.['includeDirectories'];
if (scopeDirs?.includes(removeDirTarget)) {
const updated = scopeDirs.filter((d: string) => d !== removeDirTarget);
settings.setValue(scope, 'context.includeDirectories', updated);
break;
}
}
setDirRefreshKey((k) => k + 1);
setRemoveDirTarget(null);
setView('ws-dir-list');
}, [removeDirTarget, workspaceContext, settings]);
// Filter rules for current tab
const currentTabRules = useMemo(() => {
if (activeTab.id === 'workspace') return [];
return allRules.filter((r) => r.type === activeTab.id);
}, [allRules, activeTab.id]);
// Search-filtered rules
const filteredRules = useMemo(() => {
if (!searchQuery.trim()) return currentTabRules;
const q = searchQuery.toLowerCase();
return currentTabRules.filter(
(r) =>
r.rule.raw.toLowerCase().includes(q) ||
r.rule.toolName.toLowerCase().includes(q),
);
}, [currentTabRules, searchQuery]);
// Build radio items: "Add a new rule..." + filtered rules
const listItems = useMemo(() => {
const items: Array<{
label: string;
value: string;
key: string;
}> = [
{
label: t('Add a new rule…'),
value: '__add__',
key: '__add__',
},
];
for (const r of filteredRules) {
items.push({
label: `${r.rule.raw}`,
value: r.rule.raw,
key: `${r.type}-${r.scope}-${r.rule.raw}`,
});
}
return items;
}, [filteredRules]);
// --- Action handlers ---
const handleTabCycle = useCallback(
(direction: 1 | -1) => {
const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length;
setActiveTabIndex(newIndex);
setSearchQuery('');
setIsSearchActive(false);
setDirInputError('');
// Set the appropriate default view for each tab
const newTab = tabs[newIndex]!;
setView(newTab.id === 'workspace' ? 'ws-dir-list' : 'rule-list');
},
[activeTabIndex, tabs],
);
const handleListSelect = useCallback(
(value: string) => {
if (value === '__add__') {
setNewRuleInput('');
setView('add-rule-input');
return;
}
// Selecting an existing rule → offer to delete
const found = filteredRules.find((r) => r.rule.raw === value);
if (found) {
setDeleteTarget(found);
setView('delete-confirm');
}
},
[filteredRules],
);
const handleAddRuleSubmit = useCallback(() => {
const trimmed = newRuleInput.trim();
if (!trimmed) return;
setPendingRuleText(trimmed);
setView('add-rule-scope');
}, [newRuleInput]);
const handleScopeSelect = useCallback(
(scope: SettingScope) => {
if (!pm || activeTab.id === 'workspace') return;
const ruleType = activeTab.id as RuleType;
// Add to PermissionManager in-memory
pm.addPersistentRule(pendingRuleText, ruleType);
// Persist to settings file (with dedup)
const key = `permissions.${ruleType}`;
const perms = (settings.merged as Record<string, unknown>)[
'permissions'
] as Record<string, string[]> | undefined;
const currentRules = perms?.[ruleType] ?? [];
if (!currentRules.includes(pendingRuleText)) {
settings.setValue(scope, key, [...currentRules, pendingRuleText]);
}
// Refresh and go back
refreshRules();
setView('rule-list');
setPendingRuleText('');
},
[pm, activeTab.id, pendingRuleText, settings, refreshRules],
);
const handleDeleteConfirm = useCallback(() => {
if (!pm || !deleteTarget) return;
const ruleType = deleteTarget.type;
// Remove from PermissionManager in-memory
pm.removePersistentRule(deleteTarget.rule.raw, ruleType);
// Persist removal — find and remove from settings
// We try both User and Workspace scopes
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
const perms = (scopeSettings as Record<string, unknown>)[
'permissions'
] as Record<string, string[]> | undefined;
const scopeRules = perms?.[ruleType];
if (scopeRules?.includes(deleteTarget.rule.raw)) {
const updated = scopeRules.filter(
(r: string) => r !== deleteTarget.rule.raw,
);
settings.setValue(scope, `permissions.${ruleType}`, updated);
break;
}
}
refreshRules();
setDeleteTarget(null);
setView('rule-list');
}, [pm, deleteTarget, settings, refreshRules]);
// --- Keypress handling ---
useKeypress(
(key) => {
if (view === 'rule-list') {
if (key.name === 'escape') {
if (isSearchActive && searchQuery) {
setSearchQuery('');
setIsSearchActive(false);
} else {
onExit();
}
return;
}
if (key.name === 'tab') {
handleTabCycle(1);
return;
}
if (key.name === 'right' || key.name === 'left') {
handleTabCycle(key.name === 'right' ? 1 : -1);
return;
}
// Search input: backspace
if (key.name === 'backspace' || key.name === 'delete') {
if (searchQuery.length > 0) {
setSearchQuery((prev) => prev.slice(0, -1));
}
return;
}
// Search input: printable characters
if (
key.sequence &&
!key.ctrl &&
!key.meta &&
key.sequence.length === 1 &&
key.sequence >= ' '
) {
setSearchQuery((prev) => prev + key.sequence);
setIsSearchActive(true);
return;
}
}
if (view === 'add-rule-input') {
if (key.name === 'escape') {
setView('rule-list');
return;
}
}
if (view === 'add-rule-scope') {
if (key.name === 'escape') {
setView('add-rule-input');
return;
}
}
if (view === 'delete-confirm') {
if (key.name === 'escape') {
setDeleteTarget(null);
setView('rule-list');
return;
}
if (key.name === 'return') {
handleDeleteConfirm();
return;
}
}
// Workspace tab views
if (view === 'ws-dir-list') {
if (key.name === 'escape') {
onExit();
return;
}
if (key.name === 'tab') {
handleTabCycle(1);
return;
}
if (key.name === 'right' || key.name === 'left') {
handleTabCycle(key.name === 'right' ? 1 : -1);
return;
}
}
if (view === 'ws-add-dir-input') {
if (key.name === 'escape') {
setDirInputError('');
setView('ws-dir-list');
return;
}
}
if (view === 'ws-remove-confirm') {
if (key.name === 'escape') {
setRemoveDirTarget(null);
setView('ws-dir-list');
return;
}
if (key.name === 'return') {
handleRemoveDirConfirm();
return;
}
}
},
{ isActive: true },
);
// --- Workspace tab: add directory input ---
if (activeTab.id === 'workspace' && view === 'ws-add-dir-input') {
return (
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{t('Add directory to workspace')}
</Text>
<Box height={1} />
<Text color={theme.text.secondary} wrap="wrap">
{t(
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
)}
</Text>
<Box height={1} />
<Text>{t('Enter the path to the directory:')}</Text>
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
marginTop={1}
>
<TextInput
key={dirInputRemountKey}
value={newDirInput}
onChange={handleDirInputChange}
onSubmit={handleAddDirSubmit}
onTab={dirCompletions.length > 0 ? handleDirTabComplete : undefined}
onUp={dirCompletions.length > 0 ? handleDirCompletionUp : undefined}
onDown={
dirCompletions.length > 0 ? handleDirCompletionDown : undefined
}
placeholder={t('Enter directory path…')}
isActive={true}
validationErrors={dirInputError ? [dirInputError] : []}
/>
</Box>
{/* Filesystem completions: ↑/↓ to navigate, Tab to apply */}
{dirCompletions.length > 0 && (
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
{dirCompletions.map((completion, idx) => {
const name = nodePath.basename(completion);
const isSelected = idx === completionIndex;
return (
<Box key={completion}>
<Text
bold={isSelected}
color={
isSelected ? theme.text.primary : theme.text.secondary
}
>
{`${name}/`}
</Text>
<Text color={theme.text.secondary}>{` directory`}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Tab to complete · Enter to add · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
// --- Workspace tab: remove directory confirmation ---
if (
activeTab.id === 'workspace' &&
view === 'ws-remove-confirm' &&
removeDirTarget
) {
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>{t('Remove directory?')}</Text>
<Box height={1} />
<Box marginLeft={2} flexDirection="column">
<Text bold>{removeDirTarget}</Text>
</Box>
<Box height={1} />
<Text>
{t(
'Are you sure you want to remove this directory from the workspace?',
)}
</Text>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to confirm · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
// --- Workspace tab: directory list (default) ---
if (activeTab.id === 'workspace') {
const initialDirArray = Array.from(initialDirs);
return (
<Box flexDirection="column">
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
<Text color={theme.text.secondary} wrap="wrap">
{t(
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
)}
</Text>
<Box height={1} />
{/* Initial (non-removable) dirs: shown inline with dash, same visual level as list */}
{initialDirArray.map((dir, idx) => (
<Box key={dir} marginLeft={2}>
<Text color={theme.text.secondary}>{'- '}</Text>
<Text>{dir}</Text>
<Text color={theme.text.secondary}>
{idx === 0
? t(' (Original working directory)')
: t(' (from settings)')}
</Text>
</Box>
))}
{/* Selectable list: runtime-added dirs + 'Add directory…' at end */}
<RadioButtonSelect
items={dirListItems}
onSelect={handleDirListSelect}
isFocused={view === 'ws-dir-list'}
showNumbers={true}
showScrollArrows={false}
maxItemsToShow={15}
/>
<FooterHint view={view} />
</Box>
);
}
// --- Render views ---
if (view === 'add-rule-input') {
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Add {{type}} permission rule', { type: activeTab.id })}
</Text>
<Box height={1} />
<Text wrap="wrap">
{t(
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
)}
</Text>
<Text>
{t('e.g.,')} <Text bold>WebFetch</Text> {t('or')}{' '}
<Text bold>Bash(ls:*)</Text>
</Text>
<Box height={1} />
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
>
<TextInput
value={newRuleInput}
onChange={setNewRuleInput}
onSubmit={handleAddRuleSubmit}
placeholder={t('Enter permission rule…')}
isActive={true}
/>
</Box>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to submit · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
if (view === 'add-rule-scope') {
const scopeItems = getPermScopeItems();
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Add {{type}} permission rule', { type: activeTab.id })}
</Text>
<Box height={1} />
<Box marginLeft={2} flexDirection="column">
<Text bold>{pendingRuleText}</Text>
<Text color={theme.text.secondary}>
{describeRule(pendingRuleText)}
</Text>
</Box>
<Box height={1} />
<Text>{t('Where should this rule be saved?')}</Text>
<RadioButtonSelect
items={scopeItems.map((s) => ({
label: `${s.label} ${s.description}`,
value: s.value,
key: s.key,
}))}
onSelect={handleScopeSelect}
isFocused={true}
showNumbers={true}
/>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to confirm · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
if (view === 'delete-confirm' && deleteTarget) {
return (
<Box flexDirection="column">
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
>
<Text bold>
{t('Delete {{type}} rule?', { type: deleteTarget.type })}
</Text>
<Box height={1} />
<Box marginLeft={2} flexDirection="column">
<Text bold>{deleteTarget.rule.raw}</Text>
<Text color={theme.text.secondary}>
{describeRule(deleteTarget.rule.raw)}
</Text>
<Text color={theme.text.secondary}>
{scopeLabel(deleteTarget.scope)}
</Text>
</Box>
<Box height={1} />
<Text>
{t('Are you sure you want to delete this permission rule?')}
</Text>
</Box>
<Box marginTop={1} marginLeft={1}>
<Text color={theme.text.secondary}>
{t('Enter to confirm · Esc to cancel')}
</Text>
</Box>
</Box>
);
}
// --- Default: rule-list view ---
return (
<Box flexDirection="column">
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
<Text>{activeTab.description}</Text>
{/* Search box */}
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
paddingRight={1}
width={60}
>
<Text color={theme.text.accent}>{'> '}</Text>
{searchQuery ? (
<Text>{searchQuery}</Text>
) : (
<Text color={Colors.Gray}>{t('Search…')}</Text>
)}
</Box>
<Box height={1} />
{/* Rule list */}
<RadioButtonSelect
items={listItems}
onSelect={handleListSelect}
isFocused={view === 'rule-list'}
showNumbers={true}
showScrollArrows={false}
maxItemsToShow={15}
/>
<FooterHint view={view} />
</Box>
);
}
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
function TabBar({
tabs,
activeIndex,
}: {
tabs: Tab[];
activeIndex: number;
}): React.JSX.Element {
return (
<Box marginBottom={1}>
<Text color={theme.text.accent} bold>
{t('Permissions:')}{' '}
</Text>
{tabs.map((tab, i) => (
<Box key={tab.id} marginRight={2}>
{i === activeIndex ? (
<Text
bold
backgroundColor={theme.text.accent}
color={theme.background.primary}
>
{` ${tab.label} `}
</Text>
) : (
<Text color={theme.text.secondary}>{` ${tab.label} `}</Text>
)}
</Box>
))}
<Text color={theme.text.secondary}>{t('(←/→ or tab to cycle)')}</Text>
</Box>
);
}
function FooterHint({ view }: { view: DialogView }): React.JSX.Element {
if (view !== 'rule-list' && view !== 'ws-dir-list') return <></>;
return (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
)}
</Text>
</Box>
);
}

View file

@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
const { message, plan, rejected } = data;
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
<Text color={messageColor} wrap="wrap">
{message}
</Text>
</Box>

View file

@ -33,13 +33,13 @@ describe('ShellConfirmationDialog', () => {
expect(select).toContain('Yes, allow once');
});
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
it('calls onConfirm with ProceedAlwaysProject when "Always allow in this project" is selected', () => {
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the second option
expect(select).toContain('Yes, allow always for this session');
expect(select).toContain('Always allow in this project');
});
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {

View file

@ -57,9 +57,14 @@ export const ShellConfirmationDialog: React.FC<
key: 'Yes, allow once',
},
{
label: t('Yes, allow always for this session'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always for this session',
label: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
},
{
label: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
},
{
label: t('No (esc)'),

View file

@ -9,13 +9,13 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Mock } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { TrustDialog } from './TrustDialog.js';
import { TrustLevel } from '../../config/trustedFolders.js';
import { waitFor, act } from '@testing-library/react';
import * as processUtils from '../../utils/processUtils.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
import { useTrustModify } from '../hooks/useTrustModify.js';
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
// Hoist mocks for dependencies of the useTrustModify hook
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
@ -39,16 +39,16 @@ vi.mock('../../config/trustedFolders.js', () => ({
},
}));
vi.mock('../hooks/usePermissionsModifyTrust.js');
vi.mock('../hooks/useTrustModify.js');
describe('PermissionsModifyTrustDialog', () => {
describe('TrustDialog', () => {
let mockUpdateTrustLevel: Mock;
let mockCommitTrustLevelChange: Mock;
beforeEach(() => {
mockUpdateTrustLevel = vi.fn();
mockCommitTrustLevelChange = vi.fn();
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
vi.mocked(useTrustModify).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
@ -66,7 +66,7 @@ describe('PermissionsModifyTrustDialog', () => {
it('should render the main dialog with current trust level', async () => {
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
@ -77,7 +77,7 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should display the inherited trust note from parent', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
vi.mocked(useTrustModify).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: true,
@ -88,7 +88,7 @@ describe('PermissionsModifyTrustDialog', () => {
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
@ -99,7 +99,7 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should display the inherited trust note from IDE', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
vi.mocked(useTrustModify).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
@ -110,7 +110,7 @@ describe('PermissionsModifyTrustDialog', () => {
isFolderTrustEnabled: true,
});
const { lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
);
await waitFor(() => {
@ -123,7 +123,7 @@ describe('PermissionsModifyTrustDialog', () => {
it('should call onExit when escape is pressed', async () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => {
const mockRelaunchApp = vi
.spyOn(processUtils, 'relaunchApp')
.mockResolvedValue(undefined);
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
vi.mocked(useTrustModify).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
@ -154,7 +154,7 @@ describe('PermissionsModifyTrustDialog', () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
@ -171,7 +171,7 @@ describe('PermissionsModifyTrustDialog', () => {
});
it('should not commit when escape is pressed during restart prompt', async () => {
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
vi.mocked(useTrustModify).mockReturnValue({
cwd: '/test/dir',
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
isInheritedTrustFromParent: false,
@ -184,7 +184,7 @@ describe('PermissionsModifyTrustDialog', () => {
const onExit = vi.fn();
const { stdin, lastFrame } = renderWithProviders(
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
);
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));

View file

@ -8,13 +8,13 @@ import { Box, Text } from 'ink';
import type React from 'react';
import { TrustLevel } from '../../config/trustedFolders.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
import { useTrustModify } from '../hooks/useTrustModify.js';
import { theme } from '../semantic-colors.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
interface PermissionsModifyTrustDialogProps {
interface TrustDialogProps {
onExit: () => void;
addItem: UseHistoryManagerReturn['addItem'];
}
@ -37,10 +37,10 @@ const TRUST_LEVEL_ITEMS = [
},
];
export function PermissionsModifyTrustDialog({
export function TrustDialog({
onExit,
addItem,
}: PermissionsModifyTrustDialogProps): React.JSX.Element {
}: TrustDialogProps): React.JSX.Element {
const {
cwd,
currentTrustLevel,
@ -49,7 +49,7 @@ export function PermissionsModifyTrustDialog({
needsRestart,
updateTrustLevel,
commitTrustLevelChange,
} = usePermissionsModifyTrust(onExit, addItem);
} = useTrustModify(onExit, addItem);
useKeypress(
(key) => {

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
Spinner cancel, 5s)"
" MockResponding This is an extremely long loading phrase that should be truncated in (5s · esc to
Spinner cancel)"
`;

View file

@ -7,7 +7,7 @@ exports[`LoopDetectionConfirmation > renders correctly 1`] = `
│ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
│ detection enabled or disable it for this session? │
│ │
1. Keep loop detection enabled (esc) │
1. Keep loop detection enabled (esc) │
│ 2. Disable loop detection for this session │
│ │
│ Note: To disable loop detection checks for all future sessions, set "model.skipLoopDetection" to │

View file

@ -13,9 +13,10 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Yes, allow once │
│ 2. Yes, allow always for this session │
│ 3. No (esc) │
1. Yes, allow once │
│ 2. Always allow in this project │
│ 3. Always allow for this user │
│ 4. No (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;

View file

@ -5,7 +5,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
│ │
│ > Apply To │
│ │
1. User Settings │
1. User Settings │
│ 2. Workspace Settings │
│ │
│ (Use Enter to apply scope, Tab to go back) │
@ -19,7 +19,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
│ > Select Theme Preview │
│ ▲ ┌─────────────────────────────────────────────────┐ │
│ 1. Qwen Light Light │ │ │
2. Qwen Dark Dark │ 1 # function │ │
2. Qwen Dark Dark │ 1 # function │ │
│ 3. ANSI Dark │ 2 def fibonacci(n): │ │
│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │
│ 5. Ayu Dark │ 4 for _ in range(n): │ │

View file

@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentChatView displays a single in-process agent's conversation.
*
* Renders the agent's message history using HistoryItemDisplay the same
* component used by the main agent view. AgentMessage[] is converted to
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
* are available without duplicating rendering logic.
*
* Layout:
* - Static area: finalized messages (efficient Ink <Static>)
* - Live area: tool groups still executing / awaiting confirmation
* - Status line: spinner while the agent is running
*
* Model text output is shown only after each round completes (no live
* streaming), which avoids per-chunk re-renders and keeps the display simple.
*/
import { Box, Text, Static } from 'ink';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
getGitBranch,
type AgentStatusChangeEvent,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
import { ToolCallStatus } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import { AgentHeader } from './AgentHeader.js';
// ─── Main Component ─────────────────────────────────────────
interface AgentChatViewProps {
agentId: string;
}
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
const { agents } = useAgentViewState();
const { setAgentShellFocused } = useAgentViewActions();
const uiState = useUIState();
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
uiState;
const { columns: terminalWidth } = useTerminalSize();
const agent = agents.get(agentId);
const contentWidth = terminalWidth - 4;
// Force re-render on message updates and status changes.
// STREAM_TEXT is deliberately excluded — model text is shown only after
// each round completes (via committed messages), avoiding per-chunk re-renders.
const [, setRenderTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setRenderTick(tickRef.current);
}, []);
useEffect(() => {
if (!agent) return;
const emitter = agent.interactiveAgent.getEventEmitter();
if (!emitter) return;
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
const onToolCall = () => forceRender();
const onToolResult = () => forceRender();
const onRoundEnd = () => forceRender();
const onApproval = () => forceRender();
const onOutputUpdate = () => forceRender();
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
return () => {
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
};
}, [agent, forceRender]);
const interactiveAgent = agent?.interactiveAgent;
const messages = interactiveAgent?.getMessages() ?? [];
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const liveOutputs = interactiveAgent?.getLiveOutputs();
const shellPids = interactiveAgent?.getShellPids();
const status = interactiveAgent?.getStatus();
const isRunning =
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
// Derive the active PTY PID: first shell PID among currently-executing tools.
// Resets naturally to undefined when the tool finishes (shellPids cleared).
const activePtyId =
shellPids && shellPids.size > 0
? shellPids.values().next().value
: undefined;
// Track whether the user has toggled input focus into the embedded shell.
// Mirrors the main agent's embeddedShellFocused in AppContainer.
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
// when an agent's embedded shell is focused.
useEffect(() => {
setAgentShellFocused(embeddedShellFocused);
return () => setAgentShellFocused(false);
}, [embeddedShellFocused, setAgentShellFocused]);
// Reset focus when the shell exits (activePtyId disappears).
useEffect(() => {
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
}, [activePtyId]);
// Ctrl+F: toggle shell input focus when a PTY is active.
useKeypress(
(key) => {
if (key.ctrl && key.name === 'f') {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocusedLocal((prev) => !prev);
}
}
},
{ isActive: true },
);
// Convert AgentMessage[] → HistoryItem[] via adapter.
// tickRef.current in deps ensures we rebuild when events fire even if
// messages.length and pendingApprovals.size haven't changed (e.g. a
// tool result updates an existing entry in place).
const allItems = useMemo(
() =>
agentMessagesToHistoryItems(
messages,
pendingApprovals ?? new Map(),
liveOutputs,
shellPids,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
agentId,
messages.length,
pendingApprovals?.size,
liveOutputs?.size,
shellPids?.size,
tickRef.current,
],
);
// Split into committed (Static) and pending (live area).
// Any tool_group with an Executing or Confirming tool — plus everything
// after it — stays in the live area so confirmation dialogs remain
// interactive (Ink's <Static> cannot receive input).
const splitIndex = useMemo(() => {
for (let idx = allItems.length - 1; idx >= 0; idx--) {
const item = allItems[idx]!;
if (
item.type === 'tool_group' &&
item.tools.some(
(t) =>
t.status === ToolCallStatus.Executing ||
t.status === ToolCallStatus.Confirming,
)
) {
return idx;
}
}
return allItems.length; // all committed
}, [allItems]);
const committedItems = allItems.slice(0, splitIndex);
const pendingItems = allItems.slice(splitIndex);
const core = interactiveAgent?.getCore();
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
// Cache the branch — it won't change during the agent's lifetime and
// getGitBranch uses synchronous execSync which blocks the render loop.
const agentGitBranch = useMemo(
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agentId],
);
if (!agent || !interactiveAgent || !core) {
return (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
}
const agentModelId = core.modelConfig.model ?? '';
return (
<Box flexDirection="column">
{/* Committed message history.
key includes historyRemountKey: when refreshStatic() clears the
terminal it bumps the key, forcing Static to remount and re-emit
all items on the cleared screen. */}
<Static
key={`agent-${agentId}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={agent.modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* Live area tool groups awaiting confirmation or still executing.
Must remain outside Static so confirmation dialogs are interactive.
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
{pendingItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={true}
activeShellPtyId={activePtyId ?? null}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentComposer footer area for in-process agent tabs.
*
* Replaces the main Composer when an agent tab is active so that:
* - The loading indicator reflects the agent's status (not the main agent)
* - The input prompt sends messages to the agent (via enqueueMessage)
* - Keyboard events are scoped no conflict with the main InputPrompt
*
* Wraps its content in a local StreamingContext.Provider so reusable
* components like LoadingIndicator and GeminiRespondingSpinner read the
* agent's derived streaming state instead of the main agent's.
*/
import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AgentStatus,
isTerminalStatus,
ApprovalMode,
APPROVAL_MODES,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { StreamingState } from '../../types.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
import { BaseTextInput } from '../BaseTextInput.js';
import { LoadingIndicator } from '../LoadingIndicator.js';
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
import { AgentFooter } from './AgentFooter.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
// ─── Types ──────────────────────────────────────────────────
interface AgentComposerProps {
agentId: string;
}
// ─── Component ──────────────────────────────────────────────
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
useAgentViewState();
const {
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
} = useAgentViewActions();
const agent = agents.get(agentId);
const interactiveAgent = agent?.interactiveAgent;
const config = useConfig();
const { columns: terminalWidth } = useTerminalSize();
const { inputWidth } = calculatePromptWidths(terminalWidth);
const { stdin, setRawMode } = useStdin();
const {
status,
streamingState,
isInputActive,
elapsedTime,
lastPromptTokenCount,
} = useAgentStreamingState(interactiveAgent);
// ── Escape to cancel the active agent round ──
useKeypress(
(key) => {
if (
key.name === 'escape' &&
streamingState === StreamingState.Responding
) {
interactiveAgent?.cancelCurrentRound();
}
},
{
isActive:
streamingState === StreamingState.Responding && !agentShellFocused,
},
);
// ── Shift+Tab to cycle this agent's approval mode ──
const agentApprovalMode =
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
useKeypress(
(key) => {
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
}
},
{ isActive: !agentShellFocused },
);
// ── Input buffer (independent from main agent) ──
const isValidPath = useCallback((): boolean => false, []);
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 3, width: inputWidth },
stdin,
setRawMode,
isValidPath,
});
// Sync agent buffer text to context so AgentTabBar can guard tab switching
useEffect(() => {
setAgentInputBufferText(buffer.text);
return () => setAgentInputBufferText('');
}, [buffer.text, setAgentInputBufferText]);
// When agent input is not active (agent running, completed, etc.),
// auto-focus the tab bar so arrow keys switch tabs directly.
// We also depend on streamingState so that transitions like
// WaitingForConfirmation → Responding re-trigger the effect — the
// approval keypress releases tab-bar focus (printable char handler),
// but isInputActive stays false throughout, so without this extra
// dependency the focus would never be restored.
useEffect(() => {
if (!isInputActive) {
setAgentTabBarFocused(true);
}
}, [isInputActive, streamingState, setAgentTabBarFocused]);
// ── Focus management between input and tab bar ──
const handleKeypress = useCallback(
(key: Key): boolean => {
// When tab bar has focus, block all non-printable keys so they don't
// act on the hidden buffer. Printable characters fall through to
// BaseTextInput naturally; the tab bar handler releases focus on the
// same event so the keystroke appears in the input immediately.
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
if (
buffer.text === '' ||
buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
) {
setAgentTabBarFocused(true);
return true;
}
}
return false;
},
[buffer, agentTabBarFocused, setAgentTabBarFocused],
);
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// When agent becomes idle (and not terminal), flush queued messages.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
const combined = messageQueue.join('\n');
setMessageQueue([]);
interactiveAgent?.enqueueMessage(combined);
}
}, [streamingState, messageQueue, interactiveAgent, status]);
const handleSubmit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || !interactiveAgent) return;
if (streamingState === StreamingState.Idle) {
interactiveAgent.enqueueMessage(trimmed);
} else {
setMessageQueue((prev) => [...prev, trimmed]);
}
},
[interactiveAgent, streamingState],
);
// ── Render ──
const statusLabel = useMemo(() => {
switch (status) {
case AgentStatus.COMPLETED:
return { text: t('Completed'), color: theme.status.success };
case AgentStatus.FAILED:
return {
text: t('Failed: {{error}}', {
error:
interactiveAgent?.getError() ??
interactiveAgent?.getLastRoundError() ??
'unknown',
}),
color: theme.status.error,
};
case AgentStatus.CANCELLED:
return { text: t('Cancelled'), color: theme.text.secondary };
default:
return null;
}
}, [status, interactiveAgent]);
// ── Approval-mode styling (mirrors main InputPrompt) ──
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
const statusColor = isYolo
? theme.status.errorDim
: isAutoAccept
? theme.status.warningDim
: undefined;
const inputBorderColor =
!isInputActive || agentTabBarFocused
? theme.border.default
: (statusColor ?? theme.border.focused);
const prefixNode = (
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
);
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginTop={1}>
{/* Loading indicator mirrors main Composer but reads agent's
streaming state via the overridden StreamingContext. */}
<LoadingIndicator
currentLoadingPhrase={
streamingState === StreamingState.Responding
? t('Thinking…')
: undefined
}
elapsedTime={elapsedTime}
/>
{/* Terminal status for completed/failed agents */}
{statusLabel && (
<Box marginLeft={2}>
<Text color={statusLabel.color}>{statusLabel.text}</Text>
</Box>
)}
<QueuedMessageDisplay messageQueue={messageQueue} />
{/* Input prompt — always visible, like the main Composer */}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmit}
onKeypress={handleKeypress}
showCursor={isInputActive && !agentTabBarFocused}
placeholder={' ' + t('Send a message to this agent')}
prefix={prefixNode}
borderColor={inputBorderColor}
isActive={isInputActive && !agentShellFocused}
/>
{/* Footer: approval mode + context usage */}
<AgentFooter
approvalMode={agentApprovalMode}
promptTokenCount={lastPromptTokenCount}
contextWindowSize={
config.getContentGeneratorConfig()?.contextWindowSize
}
terminalWidth={terminalWidth}
/>
</Box>
</StreamingContext.Provider>
);
};

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Lightweight footer for agent tabs showing approval mode
* and context usage. Mirrors the main Footer layout but without
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
import { theme } from '../../semantic-colors.js';
interface AgentFooterProps {
approvalMode: ApprovalMode | undefined;
promptTokenCount: number;
contextWindowSize: number | undefined;
terminalWidth: number;
}
export const AgentFooter: React.FC<AgentFooterProps> = ({
approvalMode,
promptTokenCount,
contextWindowSize,
terminalWidth,
}) => {
const showApproval =
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
if (!showApproval && !showContext) {
return null;
}
return (
<Box
justifyContent="space-between"
width="100%"
flexDirection="row"
alignItems="center"
>
<Box marginLeft={2}>
{showApproval ? (
<AutoAcceptIndicator approvalMode={approvalMode} />
) : null}
</Box>
<Box marginRight={2}>
{showContext && (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize!}
/>
</Text>
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Compact header for agent tabs, visually distinct from the
* main view's boxed logo header. Shows model, working directory, and git
* branch in a bordered info panel.
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface AgentHeaderProps {
modelId: string;
modelName?: string;
workingDirectory: string;
gitBranch?: string;
}
export const AgentHeader: React.FC<AgentHeaderProps> = ({
modelId,
modelName,
workingDirectory,
gitBranch,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const maxPathLen = Math.max(20, terminalWidth - 12);
const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen);
const modelText =
modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId;
return (
<Box
flexDirection="column"
marginX={2}
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text>
<Text color={theme.text.secondary}>{'Model: '}</Text>
<Text color={theme.text.primary}>{modelText}</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>{'Path: '}</Text>
<Text color={theme.text.primary}>{displayPath}</Text>
</Text>
{gitBranch && (
<Text>
<Text color={theme.text.secondary}>{'Branch: '}</Text>
<Text color={theme.text.primary}>{gitBranch}</Text>
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentTabBar horizontal tab strip for in-process agent views.
*
* Rendered at the top of the terminal whenever in-process agents are registered.
*
* On the main tab, Left/Right switch tabs when the input buffer is empty.
* On agent tabs, the tab bar uses an exclusive-focus model:
* - Down arrow at the input's bottom edge focuses the tab bar
* - Left/Right switch tabs only when the tab bar is focused
* - Up arrow or typing returns focus to the input
*
* Tab indicators: running, idle/completed, failed, cancelled
*/
import { Box, Text } from 'ink';
import { useState, useEffect, useCallback } from 'react';
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
type RegisteredAgent,
} from '../../contexts/AgentViewContext.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
// ─── Status Indicators ──────────────────────────────────────
function statusIndicator(agent: RegisteredAgent): {
symbol: string;
color: string;
} {
const status = agent.interactiveAgent.getStatus();
switch (status) {
case AgentStatus.RUNNING:
case AgentStatus.INITIALIZING:
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
case AgentStatus.IDLE:
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
case AgentStatus.COMPLETED:
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
case AgentStatus.FAILED:
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
case AgentStatus.CANCELLED:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
default:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
}
}
// ─── Component ──────────────────────────────────────────────
export const AgentTabBar: React.FC = () => {
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
useAgentViewState();
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
useAgentViewActions();
const { embeddedShellFocused } = useUIState();
useKeypress(
(key) => {
if (embeddedShellFocused || agentShellFocused) return;
if (!agentTabBarFocused) return;
if (key.name === 'left') {
switchToPrevious();
} else if (key.name === 'right') {
switchToNext();
} else if (key.name === 'up') {
setAgentTabBarFocused(false);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
// Printable character → return focus to input (key falls through
// to BaseTextInput's useKeypress and gets typed normally)
setAgentTabBarFocused(false);
}
},
{ isActive: true },
);
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
// Without this, status indicators would be stale until the next unrelated render.
const [, setTick] = useState(0);
const forceRender = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [, agent] of agents) {
const emitter = agent.interactiveAgent.getEventEmitter();
if (emitter) {
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
cleanups.push(() =>
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
);
}
}
return () => cleanups.forEach((fn) => fn());
}, [agents, forceRender]);
const isFocused = agentTabBarFocused;
// Navigation hint varies by context
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
return (
<Box flexDirection="row" paddingX={1}>
{/* Main tab */}
<Box marginRight={1}>
<Text
bold={activeView === 'main'}
dimColor={!isFocused}
backgroundColor={
activeView === 'main' ? theme.border.default : undefined
}
color={
activeView === 'main' ? theme.text.primary : theme.text.secondary
}
>
{' Main '}
</Text>
</Box>
{/* Separator */}
<Text dimColor={!isFocused} color={theme.border.default}>
{'\u2502'}
</Text>
{/* Agent tabs */}
{[...agents.entries()].map(([agentId, agent]) => {
const isActive = activeView === agentId;
const { symbol, color: indicatorColor } = statusIndicator(agent);
return (
<Box key={agentId} marginLeft={1}>
<Text
bold={isActive}
dimColor={!isFocused}
backgroundColor={isActive ? theme.border.default : undefined}
color={isActive ? undefined : agent.color || theme.text.secondary}
>
{` ${agent.modelId} `}
</Text>
<Text dimColor={!isFocused} color={indicatorColor}>
{` ${symbol}`}
</Text>
</Box>
);
})}
{/* Navigation hint */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{hint}</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import type {
AgentMessage,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { ToolCallStatus } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
function msg(
role: AgentMessage['role'],
content: string,
extra?: Partial<AgentMessage>,
): AgentMessage {
return { role, content, timestamp: 0, ...extra };
}
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
function toolCallMsg(
callId: string,
toolName: string,
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
): AgentMessage {
return msg('tool_call', `Tool call: ${toolName}`, {
metadata: {
callId,
toolName,
description: opts?.description ?? '',
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
},
});
}
function toolResultMsg(
callId: string,
toolName: string,
opts?: {
success?: boolean;
resultDisplay?: string;
outputFile?: string;
},
): AgentMessage {
return msg('tool_result', `Tool ${toolName}`, {
metadata: {
callId,
toolName,
success: opts?.success ?? true,
resultDisplay: opts?.resultDisplay,
outputFile: opts?.outputFile,
},
});
}
// ─── Role mapping ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — role mapping', () => {
it('maps user message', () => {
const items = agentMessagesToHistoryItems(
[msg('user', 'hello')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
});
it('maps plain assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'response')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
});
it('maps thought assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'thinking...', { thought: true })],
noApprovals,
);
expect(items[0]).toMatchObject({
type: 'gemini_thought',
text: 'thinking...',
});
});
it('maps assistant message with error metadata', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'oops', { metadata: { error: true } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
});
it('maps info message with no level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'note')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
});
it.each([
['warning', 'warning'],
['success', 'success'],
['error', 'error'],
] as const)('maps info message with level=%s', (level, expectedType) => {
const items = agentMessagesToHistoryItems(
[msg('info', 'text', { metadata: { level } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: expectedType });
});
it('maps unknown info level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'x', { metadata: { level: 'verbose' } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info' });
});
it('skips unknown roles without crashing', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'before'),
// force an unknown role
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
msg('user', 'after'),
],
noApprovals,
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
});
});
// ─── Tool grouping ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool grouping', () => {
it('merges a tool_call + tool_result pair into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]!.type).toBe('tool_group');
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(1);
expect(group.tools[0]!.name).toBe('read_file');
});
it('merges multiple parallel tool calls into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read_file'),
toolCallMsg('c2', 'write_file'),
toolResultMsg('c1', 'read_file'),
toolResultMsg('c2', 'write_file'),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(2);
expect(group.tools[0]!.name).toBe('read_file');
expect(group.tools[1]!.name).toBe('write_file');
});
it('preserves tool call order by first appearance', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c2', 'second'),
toolCallMsg('c1', 'first'),
toolResultMsg('c1', 'first'),
toolResultMsg('c2', 'second'),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.name).toBe('second');
expect(group.tools[1]!.name).toBe('first');
});
it('breaks tool groups at non-tool messages', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'tool_a'),
toolResultMsg('c1', 'tool_a'),
msg('assistant', 'between'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c2', 'tool_b'),
],
noApprovals,
);
expect(items).toHaveLength(3);
expect(items[0]!.type).toBe('tool_group');
expect(items[1]!.type).toBe('gemini');
expect(items[2]!.type).toBe('tool_group');
});
it('handles tool_result arriving without a prior tool_call gracefully', () => {
const items = agentMessagesToHistoryItems(
[
toolResultMsg('c1', 'orphan', {
success: true,
resultDisplay: 'output',
}),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.callId).toBe('c1');
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
});
// ─── Tool status ─────────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool status', () => {
it('Executing: tool_call with no result yet', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
});
it('Success: tool_result with success=true', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
it('Error: tool_result with success=false', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'write'),
toolResultMsg('c1', 'write', { success: false }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
});
it('Confirming: tool_call present in pendingApprovals', () => {
const fakeApproval = {} as ToolCallConfirmationDetails;
const approvals = new Map([['c1', fakeApproval]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
});
it('Confirming takes priority over Executing', () => {
// pending approval AND no result yet → Confirming, not Executing
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
});
});
// ─── Tool metadata ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool metadata', () => {
it('forwards resultDisplay from tool_result', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', {
success: true,
resultDisplay: 'file contents',
}),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('file contents');
});
it('forwards renderOutputAsMarkdown from tool_call', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
toolResultMsg('c1', 'web_fetch', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
});
it('forwards description from tool_call', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.description).toBe('reading src/index.ts');
});
});
// ─── liveOutputs overlay ─────────────────────────────────────
describe('agentMessagesToHistoryItems — liveOutputs', () => {
it('uses liveOutput as resultDisplay for Executing tools', () => {
const liveOutputs = new Map([['c1', 'live stdout so far']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
});
it('ignores liveOutput for completed tools', () => {
const liveOutputs = new Map([['c1', 'stale live output']]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', {
success: true,
resultDisplay: 'final output',
}),
],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('final output');
});
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
const liveOutputs = new Map([['other-id', 'unrelated']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBeUndefined();
});
});
// ─── shellPids overlay ───────────────────────────────────────
describe('agentMessagesToHistoryItems — shellPids', () => {
it('sets ptyId for Executing tools with a known PID', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBe(12345);
});
it('does not set ptyId for completed tools', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', { success: true }),
],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
it('does not set ptyId when shellPids is not provided', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
});
// ─── ID stability ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — ID stability', () => {
it('assigns monotonically increasing IDs', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'u1'),
msg('assistant', 'a1'),
msg('info', 'i1'),
toolCallMsg('c1', 'tool'),
toolResultMsg('c1', 'tool'),
],
noApprovals,
);
const ids = items.map((i) => i.id);
expect(ids).toEqual([0, 1, 2, 3]);
});
it('tool_group consumes one ID regardless of how many calls it contains', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'go'),
toolCallMsg('c1', 'tool_a'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c1', 'tool_a'),
toolResultMsg('c2', 'tool_b'),
msg('assistant', 'done'),
],
noApprovals,
);
// user=0, tool_group=1, assistant=2
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
});
it('IDs from a prefix of messages are stable when more messages are appended', () => {
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
const before = agentMessagesToHistoryItems(base, noApprovals);
const after = agentMessagesToHistoryItems(
[...base, msg('info', 'i')],
noApprovals,
);
expect(after[0]!.id).toBe(before[0]!.id);
expect(after[1]!.id).toBe(before[1]!.id);
expect(after[2]!.id).toBe(2);
});
});

View file

@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview agentHistoryAdapter converts AgentMessage[] to HistoryItem[].
*
* This adapter bridges the sub-agent data model (AgentMessage[] from
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
* are never coupled to CLI rendering types.
*
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
* only ever grows. Index-based IDs are therefore stable Ink's <Static>
* requires items never shift or be removed, which this guarantees.
*/
import type {
AgentMessage,
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
/**
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
*
* Consecutive tool_call / tool_result messages are merged into a single
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
* ToolGroupMessage can render confirmation dialogs.
*
* liveOutputs (optional) provides real-time display data for executing tools.
* shellPids (optional) provides PTY PIDs for interactive shell tools so
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
*/
export function agentMessagesToHistoryItems(
messages: readonly AgentMessage[],
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
shellPids?: ReadonlyMap<string, number>,
): HistoryItem[] {
const items: HistoryItem[] = [];
let nextId = 0;
let i = 0;
while (i < messages.length) {
const msg = messages[i]!;
// ── user ──────────────────────────────────────────────────
if (msg.role === 'user') {
items.push({ type: 'user', text: msg.content, id: nextId++ });
i++;
// ── assistant ─────────────────────────────────────────────
} else if (msg.role === 'assistant') {
if (msg.metadata?.['error']) {
items.push({ type: 'error', text: msg.content, id: nextId++ });
} else if (msg.thought) {
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
} else {
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
}
i++;
// ── info / warning / success / error ──────────────────────
} else if (msg.role === 'info') {
const level = msg.metadata?.['level'] as string | undefined;
const type =
level === 'warning' || level === 'success' || level === 'error'
? level
: 'info';
items.push({ type, text: msg.content, id: nextId++ });
i++;
// ── tool_call / tool_result → tool_group ──────────────────
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
const groupId = nextId++;
const callMap = new Map<
string,
{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | string | undefined;
outputFile: string | undefined;
renderOutputAsMarkdown: boolean | undefined;
success: boolean | undefined;
}
>();
const callOrder: string[] = [];
while (
i < messages.length &&
(messages[i]!.role === 'tool_call' ||
messages[i]!.role === 'tool_result')
) {
const m = messages[i]!;
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
if (m.role === 'tool_call') {
if (!callMap.has(callId)) callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: (m.metadata?.['description'] as string) ?? '',
resultDisplay: undefined,
outputFile: undefined,
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
| boolean
| undefined,
success: undefined,
});
} else {
// tool_result — attach to existing call entry
const entry = callMap.get(callId);
const resultDisplay = m.metadata?.['resultDisplay'] as
| ToolResultDisplay
| string
| undefined;
const outputFile = m.metadata?.['outputFile'] as string | undefined;
const success = m.metadata?.['success'] as boolean;
if (entry) {
entry.success = success;
entry.resultDisplay = resultDisplay;
entry.outputFile = outputFile;
} else {
// Result arrived without a prior tool_call message (shouldn't
// normally happen, but handle gracefully)
callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: '',
resultDisplay,
outputFile,
renderOutputAsMarkdown: undefined,
success,
});
}
}
i++;
}
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
const entry = callMap.get(callId)!;
const approval = pendingApprovals.get(callId);
let status: ToolCallStatus;
if (approval) {
status = ToolCallStatus.Confirming;
} else if (entry.success === undefined) {
status = ToolCallStatus.Executing;
} else if (entry.success) {
status = ToolCallStatus.Success;
} else {
status = ToolCallStatus.Error;
}
// For executing tools, use live output if available (Gap 4)
const resultDisplay =
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
? liveOutputs.get(callId)
: entry.resultDisplay;
return {
callId: entry.callId,
name: entry.name,
description: entry.description,
resultDisplay,
outputFile: entry.outputFile,
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
status,
confirmationDetails: approval,
ptyId:
status === ToolCallStatus.Executing
? shellPids?.get(callId)
: undefined,
};
});
items.push({ type: 'tool_group', tools, id: groupId });
} else {
// Skip unknown roles
i++;
}
}
return items;
}

View file

@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { AgentTabBar } from './AgentTabBar.js';
export { AgentChatView } from './AgentChatView.js';
export { AgentHeader } from './AgentHeader.js';
export { AgentComposer } from './AgentComposer.js';
export { AgentFooter } from './AgentFooter.js';
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';

View file

@ -0,0 +1,290 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import type { ArenaAgentCardData } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
// ─── Agent Complete Card ────────────────────────────────────
interface ArenaAgentCardProps {
agent: ArenaAgentCardData;
width?: number;
}
export const ArenaAgentCard: React.FC<ArenaAgentCardProps> = ({
agent,
width,
}) => {
const { icon, text, color } = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.durationMs);
const tokens = agent.totalTokens.toLocaleString();
const inTokens = agent.inputTokens.toLocaleString();
const outTokens = agent.outputTokens.toLocaleString();
return (
<Box flexDirection="column" width={width}>
{/* Line 1: Status icon + text + label + duration */}
<Box>
<Text color={color}>
{icon} {agent.label} · {text} · {duration}
</Text>
</Box>
{/* Line 2: Tokens */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tokens: {tokens} (in {inTokens}, out {outTokens})
</Text>
</Box>
{/* Line 3: Tool Calls with colored success/error counts */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tool Calls: {agent.toolCalls}
{agent.failedToolCalls > 0 && (
<>
{' '}
(
<Text color={theme.status.success}>
{agent.successfulToolCalls}
</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={theme.status.error}> {agent.failedToolCalls}</Text>)
</>
)}
</Text>
</Box>
{/* Error line (if terminated with error) */}
{agent.error && (
<Box marginLeft={2}>
<Text color={theme.status.error}>{agent.error}</Text>
</Box>
)}
</Box>
);
};
// ─── Session Complete Card ──────────────────────────────────
interface ArenaSessionCardProps {
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
width?: number;
}
/**
* Pad or truncate a string to a fixed visual width.
*/
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
/**
* Truncate a string to a maximum length, adding ellipsis if truncated.
*/
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
/**
* Calculate diff stats from a unified diff string.
* Returns the stats string and individual counts for colored rendering.
*/
function getDiffStats(diff: string | undefined): {
text: string;
additions: number;
deletions: number;
} {
if (!diff) return { text: '', additions: 0, deletions: 0 };
const lines = diff.split('\n');
let additions = 0;
let deletions = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions++;
}
}
return { text: `+${additions}/-${deletions}`, additions, deletions };
}
const MAX_MODEL_NAME_LENGTH = 35;
export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
sessionStatus,
task,
agents,
width,
}) => {
// Truncate task for display
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
// Column widths for the agent table (unified with Arena Results)
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colChanges = 10;
const titleLabel =
sessionStatus === 'idle'
? 'Agents Status · Idle'
: sessionStatus === 'completed'
? 'Arena Complete'
: sessionStatus === 'cancelled'
? 'Arena Cancelled'
: 'Arena Failed';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width={width}
>
{/* Title - neutral color (not green) */}
<Box>
<Text bold color={theme.text.primary}>
{titleLabel}
</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Changes
</Text>
</Box>
</Box>
{/* Table separator */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat((width ?? 60) - 8)}
</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const diffStats = getDiffStats(agent.diff);
return (
<Box key={agent.label}>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(agent.label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(agent.durationMs), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(
agent.totalTokens.toLocaleString(),
colTokens - 1,
'right',
)}
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
{diffStats.additions > 0 || diffStats.deletions > 0 ? (
<Text>
<Text color={theme.status.success}>
+{diffStats.additions}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffStats.deletions}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>-</Text>
)}
</Box>
</Box>
);
})}
<Box height={1} />
{/* Hint */}
{sessionStatus === 'idle' && (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Switch to an agent tab to continue, or{' '}
<Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
{sessionStatus === 'completed' && (
<Box>
<Text color={theme.text.secondary}>
Run <Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,260 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
isSuccessStatus,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
interface ArenaSelectDialogProps {
manager: ArenaManager;
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaSelectDialog({
manager,
config,
addItem,
closeArenaDialog,
}: ArenaSelectDialogProps): React.JSX.Element {
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena select',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onSelect = useCallback(
async (agentId: string) => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
const agent =
mgr.getAgentState(agentId) ??
mgr.getAgentStates().find((item) => item.agentId === agentId);
const label = agent?.model.modelId || agentId;
pushMessage({
messageType: 'info',
content: `Applying changes from ${label}`,
});
const result = await mgr.applyAgentResult(agentId);
if (!result.success) {
pushMessage({
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
});
return;
}
try {
await config.cleanupArenaRuntime(true);
} catch (err) {
pushMessage({
messageType: 'error',
content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
});
}
pushMessage({
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
});
},
[closeArenaDialog, config, pushMessage],
);
const onDiscard = useCallback(async () => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true);
pushMessage({
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
} catch (err) {
pushMessage({
messageType: 'error',
content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
});
}
}, [closeArenaDialog, config, pushMessage]);
const result = manager.getResult();
const agents = manager.getAgentStates();
const items: Array<DescriptiveRadioSelectItem<string>> = useMemo(
() =>
agents.map((agent) => {
const label = agent.model.modelId;
const statusInfo = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.stats.durationMs);
const tokens = agent.stats.totalTokens.toLocaleString();
// Build diff summary from cached result if available
let diffAdditions = 0;
let diffDeletions = 0;
if (isSuccessStatus(agent.status) && result) {
const agentResult = result.agents.find(
(a) => a.agentId === agent.agentId,
);
if (agentResult?.diff) {
const lines = agentResult.diff.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
diffAdditions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
diffDeletions++;
}
}
}
}
// Title: full model name (not truncated)
const title = <Text>{label}</Text>;
// Description: status, time, tokens, changes (unified with Arena Complete columns)
const description = (
<Text>
<Text color={statusInfo.color}>{statusInfo.text}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{duration}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{tokens} tokens</Text>
{(diffAdditions > 0 || diffDeletions > 0) && (
<>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.status.success}>+{diffAdditions}</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffDeletions}</Text>
<Text color={theme.text.secondary}> lines</Text>
</>
)}
</Text>
);
return {
key: agent.agentId,
value: agent.agentId,
title,
description,
disabled: !isSuccessStatus(agent.status),
};
}),
[agents, result],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
if (key.name === 'd' && !key.ctrl && !key.meta) {
onDiscard();
}
},
{ isActive: true },
);
const task = result?.task || '';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Neutral title color (not green) */}
<Text bold color={theme.text.primary}>
Arena Results
</Text>
<Box marginTop={1} flexDirection="column">
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text
color={theme.text.primary}
>{`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`}</Text>
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Select a winner to apply changes:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={items.findIndex((item) => !item.disabled)}
onSelect={(agentId: string) => {
onSelect(agentId);
}}
isFocused={true}
showNumbers={false}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to select, d to discard all, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { AuthType } from '@qwen-code/qwen-code-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MultiSelect } from '../shared/MultiSelect.js';
import { t } from '../../../i18n/index.js';
interface ArenaStartDialogProps {
onClose: () => void;
onConfirm: (selectedModels: string[]) => void;
}
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
export function ArenaStartDialog({
onClose,
onConfirm,
}: ArenaStartDialogProps): React.JSX.Element {
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const modelItems = useMemo(() => {
const allModels = config.getAllConfiguredModels();
const selectableModels = allModels.filter((model) => !model.isRuntimeModel);
return selectableModels.map((model) => {
const token = `${model.authType}:${model.id}`;
const isQwenOauth = model.authType === AuthType.QWEN_OAUTH;
return {
key: token,
value: token,
label: `[${model.authType}] ${model.label}`,
disabled: isQwenOauth,
};
});
}, [config]);
const hasDisabledQwenOauth = modelItems.some((item) => item.disabled);
const selectableModelCount = modelItems.filter(
(item) => !item.disabled,
).length;
const needsMoreModels = selectableModelCount < 2;
const shouldShowMoreModelsHint =
selectableModelCount >= 2 && selectableModelCount < 3;
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
const handleConfirm = (values: string[]) => {
if (values.length < 2) {
setErrorMessage(
t('Please select at least 2 models to start an Arena session.'),
);
return;
}
setErrorMessage(null);
onConfirm(values);
};
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Select Models')}</Text>
{modelItems.length === 0 ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t('No models available. Please configure models first.')}
</Text>
</Box>
) : (
<Box marginTop={1}>
<MultiSelect
items={modelItems}
initialIndex={0}
onConfirm={handleConfirm}
showNumbers
showScrollArrows
maxItemsToShow={10}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{(hasDisabledQwenOauth || needsMoreModels) && (
<Box marginTop={1} flexDirection="column">
{hasDisabledQwenOauth && (
<Text color={theme.status.warning}>
{t('Note: qwen-oauth models are not supported in Arena.')}
</Text>
)}
{needsMoreModels && (
<>
<Text color={theme.status.warning}>
{t('Arena requires at least 2 models. To add more:')}
</Text>
<Text color={theme.status.warning}>
{t(
' - Run /auth to set up a Coding Plan (includes multiple models)',
)}
</Text>
<Text color={theme.status.warning}>
{t(' - Or configure modelProviders in settings.json')}
</Text>
</>
)}
</Box>
)}
{shouldShowMoreModelsHint && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Configure more models with the modelProviders guide:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
</>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Space to toggle, Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
isSettledStatus,
ArenaSessionStatus,
DISPLAY_MODE,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
const STATUS_REFRESH_INTERVAL_MS = 2000;
const IN_PROCESS_REFRESH_INTERVAL_MS = 1000;
interface ArenaStatusDialogProps {
manager: ArenaManager;
closeArenaDialog: () => void;
width?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
function getElapsedMs(agent: ArenaAgentState): number {
if (isSettledStatus(agent.status)) {
return agent.stats.durationMs;
}
return Date.now() - agent.startedAt;
}
function getSessionStatusLabel(status: ArenaSessionStatus): {
text: string;
color: string;
} {
switch (status) {
case ArenaSessionStatus.RUNNING:
return { text: 'Running', color: theme.status.success };
case ArenaSessionStatus.INITIALIZING:
return { text: 'Initializing', color: theme.status.warning };
case ArenaSessionStatus.IDLE:
return { text: 'Idle', color: theme.status.success };
case ArenaSessionStatus.COMPLETED:
return { text: 'Completed', color: theme.status.success };
case ArenaSessionStatus.CANCELLED:
return { text: 'Cancelled', color: theme.status.warning };
case ArenaSessionStatus.FAILED:
return { text: 'Failed', color: theme.status.error };
default:
return { text: String(status), color: theme.text.secondary };
}
}
const MAX_MODEL_NAME_LENGTH = 35;
export function ArenaStatusDialog({
manager,
closeArenaDialog,
width,
}: ArenaStatusDialogProps): React.JSX.Element {
const [tick, setTick] = useState(0);
// Detect in-process backend for live stats reading
const backend = manager.getBackend();
const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS;
const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null;
useEffect(() => {
const interval = isInProcess
? IN_PROCESS_REFRESH_INTERVAL_MS
: STATUS_REFRESH_INTERVAL_MS;
const timer = setInterval(() => {
setTick((prev) => prev + 1);
}, interval);
return () => clearInterval(timer);
}, [isInProcess]);
// Force re-read on every tick
void tick;
const sessionStatus = manager.getSessionStatus();
const sessionLabel = getSessionStatusLabel(sessionStatus);
const agents = manager.getAgentStates();
const task = manager.getTask() ?? '';
// For in-process mode, read live stats directly from AgentInteractive
const liveStats = useMemo(() => {
if (!inProcessBackend) return null;
const statsMap = new Map<string, AgentStatsSummary>();
for (const agent of agents) {
const interactive = inProcessBackend.getAgent(agent.agentId);
if (interactive) {
statsMap.set(agent.agentId, interactive.getStats());
}
}
return statsMap;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inProcessBackend, agents, tick]);
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colRounds = 8;
const colTools = 8;
useKeypress(
(key) => {
if (key.name === 'escape' || key.name === 'q' || key.name === 'return') {
closeArenaDialog();
}
},
{ isActive: true },
);
// Inner content width: total width minus border (2) and paddingX (2*2)
const innerWidth = (width ?? 80) - 6;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width="100%"
>
{/* Title */}
<Box>
<Text bold color={theme.text.primary}>
Arena Status
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Rounds
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tools
</Text>
</Box>
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(innerWidth)}</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const label = agent.model.modelId;
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const elapsed = getElapsedMs(agent);
// Use live stats from AgentInteractive when in-process, otherwise
// fall back to the cached ArenaAgentState.stats (file-polled).
const live = liveStats?.get(agent.agentId);
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
const rounds = live?.rounds ?? agent.stats.rounds;
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
const successfulToolCalls =
live?.successfulToolCalls ?? agent.stats.successfulToolCalls;
const failedToolCalls =
live?.failedToolCalls ?? agent.stats.failedToolCalls;
return (
<Box key={agent.agentId} flexDirection="column">
<Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(elapsed), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(String(rounds), colRounds - 1, 'right')}
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
{failedToolCalls > 0 ? (
<Text>
<Text color={theme.status.success}>
{successfulToolCalls}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>{failedToolCalls}</Text>
</Text>
) : (
<Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}
</Box>
</Box>
</Box>
);
})}
{agents.length === 0 && (
<Box>
<Text color={theme.text.secondary}>No agents registered yet.</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,213 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
ArenaSessionStatus,
createDebugLogger,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
const debugLogger = createDebugLogger('ARENA_STOP_DIALOG');
type StopAction = 'cleanup' | 'preserve';
interface ArenaStopDialogProps {
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaStopDialog({
config,
addItem,
closeArenaDialog,
}: ArenaStopDialogProps): React.JSX.Element {
const [isProcessing, setIsProcessing] = useState(false);
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena stop',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onStop = useCallback(
async (action: StopAction) => {
if (isProcessing) return;
setIsProcessing(true);
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No running Arena session found.',
});
return;
}
try {
const sessionStatus = mgr.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
pushMessage({
messageType: 'info',
content: 'Stopping Arena agents…',
});
await mgr.cancel();
}
await mgr.waitForSettled();
pushMessage({
messageType: 'info',
content: 'Cleaning up Arena resources…',
});
if (action === 'preserve') {
await mgr.cleanupRuntime();
} else {
await mgr.cleanup();
}
config.setArenaManager(null);
if (action === 'preserve') {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. Worktrees and session files were preserved. ' +
'Use /arena select --discard to manually clean up later.',
});
} else {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.',
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.error('Failed to stop Arena session:', error);
pushMessage({
messageType: 'error',
content: `Failed to stop Arena session: ${message}`,
});
}
},
[isProcessing, closeArenaDialog, config, pushMessage],
);
const configPreserve =
config.getAgentsSettings().arena?.preserveArtifacts ?? false;
const items: Array<DescriptiveRadioSelectItem<StopAction>> = useMemo(
() => [
{
key: 'cleanup',
value: 'cleanup' as StopAction,
title: <Text>Stop and clean up</Text>,
description: (
<Text color={theme.text.secondary}>
Remove all worktrees and session files
</Text>
),
},
{
key: 'preserve',
value: 'preserve' as StopAction,
title: <Text>Stop and preserve artifacts</Text>,
description: (
<Text color={theme.text.secondary}>
Keep worktrees and session files for later inspection
</Text>
),
},
],
[],
);
const defaultIndex = configPreserve ? 1 : 0;
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
},
{ isActive: !isProcessing },
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Stop Arena Session
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Choose what to do with Arena artifacts:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={defaultIndex}
onSelect={(action: StopAction) => {
onStop(action);
}}
isFocused={!isProcessing}
showNumbers={false}
/>
</Box>
{configPreserve && (
<Box marginTop={1}>
<Text color={theme.text.secondary} dimColor>
Default: preserve (agents.arena.preserveArtifacts is enabled)
</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to confirm, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -1,33 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
" View Details
" View Details
Disable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
" View Details
" View Details
Enable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
" View Details
" View Details
Update Extension
Enable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
" View Details
" View Details
Update Extension
Disable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
" View Details
" View Details
Enable Extension
Uninstall Extension"
`;

View file

@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix=""
prefix=""
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>

View file

@ -138,17 +138,17 @@ describe('ToolConfirmationMessage', () => {
{
description: 'for exec confirmations',
details: execConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
alwaysAllowText: 'Always allow in this project',
},
{
description: 'for info confirmations',
details: infoConfirmationDetails,
alwaysAllowText: 'Yes, allow always',
alwaysAllowText: 'Always allow in this project',
},
{
description: 'for mcp confirmations',
details: mcpConfirmationDetails,
alwaysAllowText: 'always allow',
alwaysAllowText: 'Always allow in this project',
},
])('$description', ({ details, alwaysAllowText }) => {
it('should show "allow always" when folder is trusted', () => {

View file

@ -242,11 +242,19 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = executionProps.permissionRules?.length
? ` [${executionProps.permissionRules.join(', ')}]`
: '';
options.push({
label: t('Yes, allow always ...'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always ...',
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({
@ -315,11 +323,21 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel =
'permissionRules' in infoProps &&
(infoProps as { permissionRules?: string[] }).permissionRules?.length
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
: '';
options.push({
label: t('Yes, allow always'),
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Yes, allow always',
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({
@ -382,21 +400,19 @@ export const ToolConfirmationMessage: React.FC<
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Yes, allow once',
});
if (isTrustedFolder) {
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = mcpProps.permissionRules?.length
? ` [${mcpProps.permissionRules.join(', ')}]`
: '';
options.push({
label: t('Yes, always allow tool "{{tool}}" from server "{{server}}"', {
tool: mcpProps.toolName,
server: mcpProps.serverName,
}),
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
label: t('Always allow in this project') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Yes, always allow all tools from server "{{server}}"', {
server: mcpProps.serverName,
}),
value: ToolConfirmationOutcome.ProceedAlwaysServer,
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
label: t('Always allow for this user') + rulesLabel,
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
}
options.push({

View file

@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('MockAnsiOutput:hello');
});
it('renders rejected plan content with plan text still visible', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'Plan was rejected. Remaining in plan mode.',
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
rejected: true,
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Canceled}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1: Do something');
expect(output).toContain('- Step 2: Do another thing');
});
it('renders approved plan content with approval message', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'User approved the plan.',
plan: '# My Plan\n- Step 1\n- Step 2',
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Success}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('User approved the plan.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1');
expect(output).toContain('- Step 2');
});
});

View file

@ -93,12 +93,12 @@ describe('BaseSelectionList', () => {
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
});
it('should render the selection indicator ( or space) and layout', () => {
it('should render the selection indicator ( or space) and layout', () => {
const { lastFrame } = renderComponent({}, 0);
const output = lastFrame();
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
expect(output).toMatch(/\s+1\.\s+Item A/);
expect(output).toMatch(/\s+1\.\s+Item A/);
expect(output).toMatch(/\s+2\.\s+Item B/);
expect(output).toMatch(/\s+3\.\s+Item C/);
});

View file

@ -138,7 +138,7 @@ export function BaseSelectionList<
color={isSelected ? theme.status.success : theme.text.primary}
aria-hidden
>
{isSelected ? '' : ' '}
{isSelected ? '' : ' '}
</Text>
</Box>

View file

@ -66,7 +66,11 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
{typeof item.description === 'string' ? (
<Text color={theme.text.secondary}>{item.description}</Text>
) : (
item.description
)}
</Box>
)}
/>

View file

@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface MultiSelectItem<T> extends SelectionListItem<T> {
label: string;
}
export interface MultiSelectProps<T> {
items: Array<MultiSelectItem<T>>;
initialIndex?: number;
initialSelectedKeys?: string[];
onConfirm: (selectedValues: T[]) => void;
onChange?: (selectedValues: T[]) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
}
const EMPTY_SELECTED_KEYS: string[] = [];
function getSelectedValues<T>(
items: Array<MultiSelectItem<T>>,
selectedKeys: Set<string>,
): T[] {
return items
.filter((item) => selectedKeys.has(item.key))
.map((item) => item.value);
}
export function MultiSelect<T>({
items,
initialIndex = 0,
initialSelectedKeys = EMPTY_SELECTED_KEYS,
onConfirm,
onChange,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
}: MultiSelectProps<T>): React.JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(initialSelectedKeys),
);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
setSelectedKeys((prev) => {
const next = new Set(initialSelectedKeys);
if (
prev.size === next.size &&
Array.from(next).every((key) => prev.has(key))
) {
return prev;
}
return next;
});
}, [initialSelectedKeys]);
const { activeIndex } = useSelectionList({
items,
initialIndex,
isFocused,
// Disable numeric quick-select in useSelectionList — in a multi-select
// context, onSelect triggers onConfirm (submit), so numeric keys would
// accidentally submit the dialog instead of toggling checkboxes.
// Numbers are still rendered visually via the showNumbers prop below.
showNumbers: false,
onHighlight,
onSelect: () => {
onConfirm(getSelectedValues(items, selectedKeys));
},
});
const toggleSelectionAtIndex = useCallback(
(index: number) => {
const item = items[index];
if (!item || item.disabled) {
return;
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
return next;
});
},
[items],
);
useEffect(() => {
onChange?.(getSelectedValues(items, selectedKeys));
}, [items, selectedKeys, onChange]);
useKeypress(
(key) => {
if (key.name === 'space' || key.sequence === ' ') {
toggleSelectionAtIndex(activeIndex);
}
},
{ isActive: isFocused },
);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
const visibleItems = useMemo(
() => items.slice(scrollOffset, scrollOffset + maxItemsToShow),
[items, scrollOffset, maxItemsToShow],
);
const numberColumnWidth = String(items.length).length;
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + maxItemsToShow < items.length;
const moreAboveCount = scrollOffset;
const moreBelowCount = Math.max(
0,
items.length - (scrollOffset + maxItemsToShow),
);
return (
<Box flexDirection="column">
{showScrollArrows && hasMoreAbove && (
<Text color={theme.text.secondary}> {moreAboveCount} more above</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isActive = activeIndex === itemIndex;
const isChecked = selectedKeys.has(item.key);
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]';
let textColor = theme.text.primary;
if (item.disabled) {
textColor = theme.text.secondary;
} else if (isActive) {
textColor = theme.status.success;
} else if (isChecked) {
textColor = theme.text.accent;
}
return (
<Box key={item.key} alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>{checkboxText}</Text>
</Box>
{showNumbers && (
<Box marginRight={1} minWidth={itemNumberText.length}>
<Text color={textColor}>{itemNumberText}</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
);
})}
{showScrollArrows && hasMoreBelow && (
<Text color={theme.text.secondary}> {moreBelowCount} more below</Text>
)}
</Box>
);
}

View file

@ -21,6 +21,12 @@ export interface TextInputProps {
value: string;
onChange: (text: string) => void;
onSubmit?: () => void;
/** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */
onTab?: () => void;
/** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */
onUp?: () => void;
/** Called when ↓ is pressed; if provided, prevents cursor-down in the buffer. */
onDown?: () => void;
placeholder?: string;
height?: number; // lines in viewport; >1 enables multiline
isActive?: boolean; // when false, ignore keypresses
@ -33,6 +39,9 @@ export function TextInput({
value,
onChange,
onSubmit,
onTab,
onUp,
onDown,
placeholder,
height = 1,
isActive = true,
@ -68,6 +77,22 @@ export function TextInput({
(key: Key) => {
if (!buffer || !isActive) return;
// Tab completion: delegate to caller instead of inserting a tab character
if (key.name === 'tab') {
onTab?.();
return;
}
// Arrow-key completion navigation: delegate to caller
if (key.name === 'up' && onUp) {
onUp();
return;
}
if (key.name === 'down' && onDown) {
onDown();
return;
}
// Submit on Enter
if (keyMatchers[Command.SUBMIT](key) || key.name === 'return') {
if (allowMultiline) {

Some files were not shown because too many files have changed in this diff Show more