mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
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:
commit
bd77eef46f
406 changed files with 55514 additions and 6431 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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([]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
packages/cli/src/commands/auth.ts
Normal file
77
packages/cli/src/commands/auth.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
500
packages/cli/src/commands/auth/handler.ts
Normal file
500
packages/cli/src/commands/auth/handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal file
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal file
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
266
packages/cli/src/commands/auth/status.test.ts
Normal file
266
packages/cli/src/commands/auth/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
|
|||
id: string;
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: [];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
uiTelemetryService,
|
||||
FatalInputError,
|
||||
ApprovalMode,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
|
|
@ -250,7 +251,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockShutdownTelemetry).toHaveBeenCalled();
|
||||
|
|
@ -300,21 +301,21 @@ describe('runNonInteractive', () => {
|
|||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
// Verify first call has type: UserQuery
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ text: 'Use a tool' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
// Verify second call (after tool execution) has isContinuation: true
|
||||
// Verify second call (after tool execution) has type: ToolResult
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
});
|
||||
|
|
@ -383,7 +384,7 @@ describe('runNonInteractive', () => {
|
|||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
|
||||
});
|
||||
|
|
@ -507,7 +508,7 @@ describe('runNonInteractive', () => {
|
|||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
|
|
@ -539,7 +540,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -694,7 +695,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -881,7 +882,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
|
|
@ -941,7 +942,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
|
|
@ -1299,7 +1300,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Message from stream-json input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-envelope',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1775,7 +1776,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Simple string content' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-string-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// UserMessage with array of text blocks
|
||||
|
|
@ -1808,7 +1809,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'First part' }, { text: 'Second part' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-blocks-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
uiTelemetryService,
|
||||
parseAndFormatApiError,
|
||||
createDebugLogger,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
|
|
@ -265,7 +266,11 @@ export async function runNonInteractive(
|
|||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
{ isContinuation: !isFirstTurn },
|
||||
{
|
||||
type: isFirstTurn
|
||||
? SendMessageType.UserQuery
|
||||
: SendMessageType.ToolResult,
|
||||
},
|
||||
);
|
||||
isFirstTurn = false;
|
||||
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
|||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -1137,6 +1138,102 @@ describe('DataProcessor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('generateQualitativeInsights', () => {
|
||||
const mockMetrics = {
|
||||
totalSessions: 5,
|
||||
totalMessages: 50,
|
||||
totalHours: 2,
|
||||
heatmap: { '2025-01-15': 3 },
|
||||
topTools: [['read_file', 10]] as Array<[string, number]>,
|
||||
activeDays: 1,
|
||||
activeHours: { '10': 5 },
|
||||
totalLinesAdded: 100,
|
||||
totalLinesRemoved: 50,
|
||||
totalFiles: 10,
|
||||
streak: { currentStreak: 1, longestStreak: 1, dates: [] },
|
||||
} as unknown as Omit<InsightData, 'facets' | 'qualitative'>;
|
||||
|
||||
const mockFacets: SessionFacets[] = [
|
||||
{
|
||||
session_id: 'test-1',
|
||||
underlying_goal: 'Fix bug',
|
||||
goal_categories: { debugging: 1 },
|
||||
outcome: 'fully_achieved',
|
||||
user_satisfaction_counts: { satisfied: 1 },
|
||||
Qwen_helpfulness: 'very_helpful',
|
||||
session_type: 'single_task',
|
||||
friction_counts: {},
|
||||
friction_detail: '',
|
||||
primary_success: 'correct_code_edits',
|
||||
brief_summary: 'Fixed a bug',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return partial qualitative data when some LLM calls fail', async () => {
|
||||
let callIndex = 0;
|
||||
mockGenerateJson.mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex % 2 === 0) {
|
||||
return Promise.reject(new Error('LLM timeout'));
|
||||
}
|
||||
return Promise.resolve({ intro: 'test', areas: [], opportunities: [] });
|
||||
});
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.impressiveWorkflows).toBeDefined();
|
||||
expect(result!.projectAreas).toBeUndefined();
|
||||
expect(result!.futureOpportunities).toBeDefined();
|
||||
expect(result!.frictionPoints).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when facets are empty', async () => {
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, []);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return full qualitative data when all LLM calls succeed', async () => {
|
||||
mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] });
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockGenerateJson).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFacets', () => {
|
||||
it('should skip non-conversational sessions', async () => {
|
||||
const userOnlyRecords: ChatRecord[] = [
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export class DataProcessor {
|
|||
const generate = async <T>(
|
||||
promptTemplate: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
): Promise<T | undefined> => {
|
||||
const prompt = `${promptTemplate}\n\n${commonData}`;
|
||||
try {
|
||||
const result = await this.config.getBaseLlmClient().generateJson({
|
||||
|
|
@ -400,7 +400,7 @@ export class DataProcessor {
|
|||
return result as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate insight:', error);
|
||||
throw error;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ export interface InsightAtAGlance {
|
|||
}
|
||||
|
||||
export interface QualitativeInsights {
|
||||
impressiveWorkflows: InsightImpressiveWorkflows;
|
||||
projectAreas: InsightProjectAreas;
|
||||
futureOpportunities: InsightFutureOpportunities;
|
||||
frictionPoints: InsightFrictionPoints;
|
||||
memorableMoment: InsightMemorableMoment;
|
||||
improvements: InsightImprovements;
|
||||
interactionStyle: InsightInteractionStyle;
|
||||
atAGlance: InsightAtAGlance;
|
||||
impressiveWorkflows?: InsightImpressiveWorkflows;
|
||||
projectAreas?: InsightProjectAreas;
|
||||
futureOpportunities?: InsightFutureOpportunities;
|
||||
frictionPoints?: InsightFrictionPoints;
|
||||
memorableMoment?: InsightMemorableMoment;
|
||||
improvements?: InsightImprovements;
|
||||
interactionStyle?: InsightInteractionStyle;
|
||||
atAGlance?: InsightAtAGlance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal file
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal file
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal 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',
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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.'));
|
||||
}
|
||||
|
|
|
|||
376
packages/cli/src/ui/commands/contextCommand.ts
Normal file
376
packages/cli/src/ui/commands/contextCommand.ts
Normal 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());
|
||||
},
|
||||
};
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal file
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/trustCommand.ts
Normal file
21
packages/cli/src/ui/commands/trustCommand.ts
Normal 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',
|
||||
}),
|
||||
};
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||
debugMessage: '',
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
taskStartTokens: 0,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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%"
|
||||
|
|
|
|||
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal file
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)'),
|
||||
|
|
|
|||
|
|
@ -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...'));
|
||||
|
|
@ -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) => {
|
||||
|
|
@ -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)"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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 │
|
||||
|
|
|
|||
|
|
@ -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) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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): │ │
|
||||
|
|
|
|||
272
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal file
272
packages/cli/src/ui/components/agent-view/AgentChatView.tsx
Normal 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 "{agentId}" 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>
|
||||
);
|
||||
};
|
||||
308
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal file
308
packages/cli/src/ui/components/agent-view/AgentComposer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal file
66
packages/cli/src/ui/components/agent-view/AgentFooter.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
packages/cli/src/ui/components/agent-view/AgentHeader.tsx
Normal file
64
packages/cli/src/ui/components/agent-view/AgentHeader.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
167
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal file
167
packages/cli/src/ui/components/agent-view/AgentTabBar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal file
194
packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts
Normal 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;
|
||||
}
|
||||
12
packages/cli/src/ui/components/agent-view/index.ts
Normal file
12
packages/cli/src/ui/components/agent-view/index.ts
Normal 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';
|
||||
290
packages/cli/src/ui/components/arena/ArenaCards.tsx
Normal file
290
packages/cli/src/ui/components/arena/ArenaCards.tsx
Normal 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}>"{displayTask}"</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>
|
||||
);
|
||||
};
|
||||
260
packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx
Normal file
260
packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
packages/cli/src/ui/components/arena/ArenaStartDialog.tsx
Normal file
161
packages/cli/src/ui/components/arena/ArenaStartDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
288
packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
Normal file
288
packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx
Normal 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}>"{displayTask}"</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>
|
||||
);
|
||||
}
|
||||
213
packages/cli/src/ui/components/arena/ArenaStopDialog.tsx
Normal file
213
packages/cli/src/ui/components/arena/ArenaStopDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
|
||||
import type React from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Box } from 'ink';
|
||||
import type { IndividualToolCallDisplay } from '../../types.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
|
|
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
|||
contentWidth={innerWidth}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
<Box marginX={1}>
|
||||
<Text color={theme.text.primary}>
|
||||
Output too long and was saved to: {tool.outputFile}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
|
|||
);
|
||||
expect(lastFrame()).toContain('MockAnsiOutput:hello');
|
||||
});
|
||||
|
||||
it('renders rejected plan content with plan text still visible', () => {
|
||||
const planResultDisplay = {
|
||||
type: 'plan_summary' as const,
|
||||
message: 'Plan was rejected. Remaining in plan mode.',
|
||||
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
|
||||
rejected: true,
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Canceled}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
|
||||
expect(output).toContain('MockMarkdown:# My Plan');
|
||||
expect(output).toContain('- Step 1: Do something');
|
||||
expect(output).toContain('- Step 2: Do another thing');
|
||||
});
|
||||
|
||||
it('renders approved plan content with approval message', () => {
|
||||
const planResultDisplay = {
|
||||
type: 'plan_summary' as const,
|
||||
message: 'User approved the plan.',
|
||||
plan: '# My Plan\n- Step 1\n- Step 2',
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage
|
||||
{...baseProps}
|
||||
name="ExitPlanMode"
|
||||
description="Plan:"
|
||||
status={ToolCallStatus.Success}
|
||||
resultDisplay={planResultDisplay}
|
||||
/>,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('User approved the plan.');
|
||||
expect(output).toContain('MockMarkdown:# My Plan');
|
||||
expect(output).toContain('- Step 1');
|
||||
expect(output).toContain('- Step 2');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function BaseSelectionList<
|
|||
color={isSelected ? theme.status.success : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? '●' : ' '}
|
||||
{isSelected ? '›' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
/>
|
||||
|
|
|
|||
193
packages/cli/src/ui/components/shared/MultiSelect.tsx
Normal file
193
packages/cli/src/ui/components/shared/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue