Merge branch 'main' into feat/hooks-plugin

This commit is contained in:
DennisYu07 2026-03-20 17:43:30 +08:00
commit 857c7fb99b
184 changed files with 14809 additions and 2046 deletions

View file

@ -64,6 +64,7 @@ import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { Session } from './session/Session.js';
import { formatAcpModelId } from '../utils/acpModelUtils.js';
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
const debugLogger = createDebugLogger('ACP_AGENT');
@ -87,7 +88,33 @@ export async function runAcpAgent(
stream,
);
// Handle SIGTERM/SIGINT for graceful shutdown.
// Without this, signal handlers registered elsewhere in the CLI
// (e.g., stdin raw mode restoration) override the default exit behavior,
// causing the ACP process to ignore termination signals.
let shuttingDown = false;
const shutdownHandler = () => {
if (shuttingDown) return;
shuttingDown = true;
debugLogger.debug('[ACP] Shutdown signal received, closing streams');
try {
process.stdin.destroy();
} catch {
// stdin may already be closed
}
try {
process.stdout.destroy();
} catch {
// stdout may already be closed
}
};
process.on('SIGTERM', shutdownHandler);
process.on('SIGINT', shutdownHandler);
await connection.closed;
process.off('SIGTERM', shutdownHandler);
process.off('SIGINT', shutdownHandler);
}
function toStdioServer(server: McpServer): McpServerStdio | undefined {
@ -188,8 +215,14 @@ class QwenAgent implements Agent {
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const sessionService = new SessionService(params.cwd);
const exists = await sessionService.sessionExists(params.sessionId);
const exists = await runWithAcpRuntimeOutputDir(
this.settings,
params.cwd,
async () => {
const sessionService = new SessionService(params.cwd);
return sessionService.sessionExists(params.sessionId);
},
);
if (!exists) {
throw RequestError.invalidParams(
undefined,
@ -230,10 +263,12 @@ class QwenAgent implements Agent {
params: ListSessionsRequest,
): Promise<ListSessionsResponse> {
const cwd = params.cwd || process.cwd();
const sessionService = new SessionService(cwd);
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
const result = await sessionService.listSessions({
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
const sessionService = new SessionService(cwd);
return sessionService.listSessions({
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
});
});
const sessions: SessionInfo[] = result.items.map((item) => ({

View file

@ -0,0 +1,34 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
describe('runWithAcpRuntimeOutputDir', () => {
beforeEach(() => {
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
it('uses the merged runtimeOutputDir relative to cwd within the async context', async () => {
const cwd = path.resolve('workspace', 'project-a');
const settings = {
merged: {
advanced: {
runtimeOutputDir: '.qwen-runtime',
},
},
} as LoadedSettings;
await runWithAcpRuntimeOutputDir(settings, cwd, async () => {
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen-runtime'));
});
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
});

View file

@ -0,0 +1,14 @@
import { Storage } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
export function runWithAcpRuntimeOutputDir<T>(
settings: LoadedSettings,
cwd: string,
fn: () => T,
): T {
return Storage.runWithRuntimeBaseDir(
settings.merged.advanced?.runtimeOutputDir,
cwd,
fn,
);
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
import type { ChatRecord, AgentResultDisplay } from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
@ -165,16 +165,16 @@ export class HistoryReplayer {
(resultDisplay as { type?: unknown }).type === 'task_execution'
) {
await this.emitTaskUsageFromResultDisplay(
resultDisplay as TaskResultDisplay,
resultDisplay as AgentResultDisplay,
);
}
}
/**
* Emits token usage from a TaskResultDisplay execution summary, if present.
* Emits token usage from a AgentResultDisplay execution summary, if present.
*/
private async emitTaskUsageFromResultDisplay(
resultDisplay: TaskResultDisplay,
resultDisplay: AgentResultDisplay,
): Promise<void> {
const summary = resultDisplay.executionSummary;
if (!summary) {

View file

@ -58,6 +58,7 @@ describe('Session', () => {
switchModel: switchModelSpy,
getModel: vi.fn().mockImplementation(() => currentModel),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
@ -241,5 +242,38 @@ describe('Session', () => {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it('runs prompt inside runtime output dir context', async () => {
const runtimeDir = path.resolve('runtime', 'from-settings');
core.Storage.setRuntimeBaseDir(runtimeDir);
session = new Session(
'test-session-id',
mockChat,
mockConfig,
mockClient,
mockSettings,
);
const runWithRuntimeBaseDirSpy = vi.spyOn(
core.Storage,
'runWithRuntimeBaseDir',
);
mockChat.sendMessageStream = vi
.fn()
.mockResolvedValue((async function* () {})());
const promptRequest: PromptRequest = {
sessionId: 'test-session-id',
prompt: [{ type: 'text', text: 'hello' }],
};
await session.prompt(promptRequest);
expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith(
runtimeDir,
process.cwd(),
expect.any(Function),
);
});
});
});

View file

@ -29,11 +29,13 @@ import {
logToolCall,
logUserPrompt,
getErrorStatus,
TaskTool,
AgentTool,
UserPromptEvent,
TodoWriteTool,
ExitPlanModeTool,
readManyFiles,
Storage,
ToolNames,
} from '@qwen-code/qwen-code-core';
import { RequestError } from '@agentclientprotocol/sdk';
@ -99,6 +101,7 @@ export class Session implements SessionContext {
*/
private pendingPromptCompletion: Promise<void> | null = null;
private turn: number = 0;
private readonly runtimeBaseDir: string;
// Modular components
private readonly historyReplayer: HistoryReplayer;
@ -117,6 +120,7 @@ export class Session implements SessionContext {
private readonly settings: LoadedSettings,
) {
this.sessionId = id;
this.runtimeBaseDir = Storage.getRuntimeBaseDir();
// Initialize modular components with this session as context
this.toolCallEmitter = new ToolCallEmitter(this);
@ -188,150 +192,170 @@ export class Session implements SessionContext {
params: PromptRequest,
pendingSend: AbortController,
): Promise<PromptResponse> {
// Increment turn counter for each user prompt
this.turn += 1;
return Storage.runWithRuntimeBaseDir(
this.runtimeBaseDir,
this.config.getWorkingDir(),
async () => {
// Increment turn counter for each user prompt
this.turn += 1;
const chat = this.chat;
const promptId = this.config.getSessionId() + '########' + this.turn;
const chat = this.chat;
const promptId = this.config.getSessionId() + '########' + this.turn;
// Extract text from all text blocks to construct the full prompt text for logging
const promptText = params.prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Extract text from all text blocks to construct the full prompt text for logging
const promptText = params.prompt
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join(' ');
// Log user prompt
logUserPrompt(
this.config,
new UserPromptEvent(
promptText.length,
promptId,
this.config.getContentGeneratorConfig()?.authType,
promptText,
),
);
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(promptText);
// Check if the input contains a slash command
// Extract text from the first text block if present
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
const inputText = firstTextBlock?.text || '';
let parts: Part[] | null;
if (isSlashCommand(inputText)) {
// Handle slash command - uses default allowed commands (init, summary, compress)
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
);
parts = await this.#processSlashCommandResult(
slashCommandResult,
params.prompt,
);
// If parts is null, the command was fully handled (e.g., /summary completed)
// Return early without sending to the model
if (parts === null) {
return { stopReason: 'end_turn' };
}
} else {
// Normal processing for non-slash commands
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
return { stopReason: 'cancelled' };
}
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try {
const responseStream = await chat.sendMessageStream(
this.config.getModel(),
{
message: nextMessage?.parts ?? [],
config: {
abortSignal: pendingSend.signal,
},
},
promptId,
// Log user prompt
logUserPrompt(
this.config,
new UserPromptEvent(
promptText.length,
promptId,
this.config.getContentGeneratorConfig()?.authType,
promptText,
),
);
nextMessage = null;
for await (const resp of responseStream) {
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(promptText);
// Check if the input contains a slash command
// Extract text from the first text block if present
const firstTextBlock = params.prompt.find(
(block) => block.type === 'text',
);
const inputText = firstTextBlock?.text || '';
let parts: Part[] | null;
if (isSlashCommand(inputText)) {
// Handle slash command - uses default allowed commands (init, summary, compress)
const slashCommandResult = await handleSlashCommand(
inputText,
pendingSend,
this.config,
this.settings,
);
parts = await this.#processSlashCommandResult(
slashCommandResult,
params.prompt,
);
// If parts is null, the command was fully handled (e.g., /summary completed)
// Return early without sending to the model
if (parts === null) {
return { stopReason: 'end_turn' };
}
} else {
// Normal processing for non-slash commands
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
}
let nextMessage: Content | null = { role: 'user', parts };
while (nextMessage !== null) {
if (pendingSend.signal.aborted) {
chat.addHistory(nextMessage);
return { stopReason: 'cancelled' };
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
continue;
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
const streamStartTime = Date.now();
try {
const responseStream = await chat.sendMessageStream(
this.config.getModel(),
{
message: nextMessage?.parts ?? [],
config: {
abortSignal: pendingSend.signal,
},
},
promptId,
);
nextMessage = null;
for await (const resp of responseStream) {
if (pendingSend.signal.aborted) {
return { stopReason: 'cancelled' };
}
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) {
continue;
}
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
);
}
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.usageMetadata
) {
usageMetadata = resp.value.usageMetadata;
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.functionCalls
) {
functionCalls.push(...resp.value.functionCalls);
}
}
} catch (error) {
if (getErrorStatus(error) === 429) {
throw new RequestError(
429,
'Rate limit exceeded. Try again later.',
);
}
throw error;
}
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
usageMetadata = resp.value.usageMetadata;
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
functionCalls.push(...resp.value.functionCalls);
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(
pendingSend.signal,
promptId,
fc,
);
toolResponseParts.push(...response);
}
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
} catch (error) {
if (getErrorStatus(error) === 429) {
throw new RequestError(429, 'Rate limit exceeded. Try again later.');
}
throw error;
}
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(pendingSend.signal, promptId, fc);
toolResponseParts.push(...response);
}
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
return { stopReason: 'end_turn' };
return { stopReason: 'end_turn' };
},
);
}
async sendUpdate(update: SessionUpdate): Promise<void> {
@ -517,7 +541,7 @@ export class Session implements SessionContext {
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
const isTodoWriteTool = tool.name === TodoWriteTool.Name;
const isTaskTool = tool.name === TaskTool.Name;
const isAgentTool = tool.name === AgentTool.Name;
const isExitPlanModeTool = tool.name === ExitPlanModeTool.Name;
// Track cleanup functions for sub-agent event listeners
@ -526,15 +550,15 @@ export class Session implements SessionContext {
try {
const invocation = tool.build(args);
if (isTaskTool && 'eventEmitter' in invocation) {
// Access eventEmitter from TaskTool invocation
if (isAgentTool && 'eventEmitter' in invocation) {
// Access eventEmitter from AgentTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: AgentEventEmitter;
}
).eventEmitter;
// Extract subagent metadata from TaskTool call
// Extract subagent metadata from AgentTool call
const parentToolCallId = callId;
const subagentType = (args['subagent_type'] as string) ?? '';
@ -553,18 +577,17 @@ export class Session implements SessionContext {
);
}
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
@ -573,7 +596,7 @@ export class Session implements SessionContext {
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
effectiveConfirmationDetails
needsConfirmation
) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
@ -584,25 +607,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,
},
});
}
@ -612,7 +645,7 @@ export class Session implements SessionContext {
const params: RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(effectiveConfirmationDetails),
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
@ -636,7 +669,7 @@ export class Session implements SessionContext {
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await effectiveConfirmationDetails.onConfirm(outcome, {
await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
});
@ -652,6 +685,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:
@ -855,13 +890,12 @@ export class Session implements SessionContext {
}
case 'no_command':
// No command was found or executed, use original prompt
return originalPrompt.map((block) => {
if (block.type === 'text') {
return { text: block.text };
}
throw new Error(`Unsupported block type: ${block.type}`);
});
// No command was found or executed, resolve the original prompt
// through the standard path that handles all block types
return this.#resolvePrompt(
originalPrompt,
new AbortController().signal,
);
default: {
// Exhaustiveness check
@ -1041,8 +1075,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,
@ -1050,13 +1089,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,
@ -1064,8 +1103,13 @@ function toPermissionOptions(
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user`,
kind: 'allow_always',
},
...basicPermissionOptions,

View file

@ -65,7 +65,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
] as const;
/**
* Tracks and emits events for sub-agent tool calls within TaskTool execution.
* Tracks and emits events for sub-agent tool calls within AgentTool execution.
*
* Uses the unified ToolCallEmitter for consistency with normal flow
* and history replay. Also handles permission requests for tools that
@ -106,7 +106,7 @@ export class SubAgentTracker {
/**
* Sets up event listeners for a sub-agent's tool events.
*
* @param eventEmitter - The AgentEventEmitter from TaskTool
* @param eventEmitter - The AgentEventEmitter from AgentTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove listeners
*/
@ -330,6 +330,8 @@ export class SubAgentTracker {
private toPermissionOptions(
confirmation: ToolCallConfirmationDetails,
): PermissionOption[] {
const hideAlwaysAllow =
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
switch (confirmation.type) {
case 'edit':
return [
@ -342,34 +344,56 @@ export class SubAgentTracker {
];
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: 'Always Allow',
kind: 'allow_always',
},
...(hideAlwaysAllow
? []
: [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: 'Always Allow in project',
kind: 'allow_always' as const,
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: 'Always Allow for user',
kind: 'allow_always' as const,
},
]),
...basicPermissionOptions,
];
case 'plan':

View file

@ -225,7 +225,13 @@ export class ToolCallEmitter extends BaseEmitter {
// Pass tool name to handle special cases like exit_plan_mode -> switch_mode
kind = this.mapToolKind(tool.kind, toolName);
} catch {
// Use defaults on build failure
// Fallback: use the description arg directly if available
if (typeof args['description'] === 'string') {
title = `${title}: ${args['description']}`;
}
if (tool.kind) {
kind = this.mapToolKind(tool.kind, toolName);
}
}
}

View file

@ -35,9 +35,9 @@ export interface SessionContext extends SessionUpdateSender {
* Subagent metadata for tracking parent tool call context.
*/
export interface SubagentMeta {
/** ID of the parent TaskTool call that created this subagent */
/** ID of the parent AgentTool call that created this subagent */
parentToolCallId?: string;
/** Type of subagent (from TaskParams.subagent_type) */
/** Type of subagent (from AgentParams.subagent_type) */
subagentType?: string;
}

View file

@ -14,6 +14,7 @@ import {
DEFAULT_QWEN_MODEL,
OutputFormat,
NativeLspService,
Storage,
} from '@qwen-code/qwen-code-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import type { Settings } from './settings.js';
@ -1022,7 +1023,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 () => {
@ -1031,7 +1032,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 () => {
@ -1039,10 +1040,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);
});
});
@ -1067,7 +1068,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);
@ -1086,7 +1087,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);
@ -1106,7 +1107,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);
@ -1123,7 +1124,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);
@ -1140,7 +1141,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);
@ -1160,7 +1161,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);
@ -1180,7 +1181,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);
@ -1193,7 +1194,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);
@ -1218,7 +1219,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);
@ -1238,7 +1239,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
@ -1834,9 +1835,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 () => {
@ -1844,9 +1845,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 () => {
@ -1854,9 +1855,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 () => {
@ -1864,9 +1865,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');
});
});
@ -2439,3 +2440,79 @@ describe('Telemetry configuration via environment variables', () => {
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
});
});
describe('loadCliConfig runtimeOutputDir', () => {
const originalArgv = process.argv;
const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR'];
beforeEach(() => {
process.argv = ['node', 'script.js'];
Storage.setRuntimeBaseDir(null);
delete process.env['QWEN_RUNTIME_DIR'];
});
afterEach(() => {
process.argv = originalArgv;
Storage.setRuntimeBaseDir(null);
if (originalRuntimeEnv !== undefined) {
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv;
} else {
delete process.env['QWEN_RUNTIME_DIR'];
}
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should set runtime base dir from settings with absolute path', async () => {
const runtimeDir = path.resolve('custom', 'runtime');
const argv = await parseArguments();
const settings: Settings = {
advanced: { runtimeOutputDir: runtimeDir },
};
await loadCliConfig(settings, argv);
expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir);
});
it('should resolve relative runtimeOutputDir against cwd', async () => {
const argv = await parseArguments();
const settings: Settings = {
advanced: { runtimeOutputDir: '.qwen' },
};
const cwd = path.resolve('workspace', 'my-project');
await loadCliConfig(settings, argv, cwd);
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
});
it('should not set runtime base dir when runtimeOutputDir is absent', async () => {
const argv = await parseArguments();
const settings: Settings = {};
await loadCliConfig(settings, argv);
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
it('should let QWEN_RUNTIME_DIR env var take priority over settings', async () => {
const envDir = path.resolve('from-env');
const settingsDir = path.resolve('from-settings');
process.env['QWEN_RUNTIME_DIR'] = envDir;
const argv = await parseArguments();
const settings: Settings = {
advanced: { runtimeOutputDir: settingsDir },
};
await loadCliConfig(settings, argv);
// getRuntimeBaseDir checks env var first at call time
expect(Storage.getRuntimeBaseDir()).toBe(envDir);
});
it('should reset runtime base dir on subsequent load when runtimeOutputDir is absent', async () => {
const argv = await parseArguments();
const firstRuntimeDir = path.resolve('first', 'runtime');
await loadCliConfig(
{ advanced: { runtimeOutputDir: firstRuntimeDir } },
argv,
);
expect(Storage.getRuntimeBaseDir()).toBe(firstRuntimeDir);
await loadCliConfig({}, argv);
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
});
});

View file

@ -18,7 +18,6 @@ import {
Storage,
InputFormat,
OutputFormat,
isToolEnabled,
SessionService,
ideContextStore,
type ResumedSessionData,
@ -30,11 +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, LoadedSettings } from './settings.js';
import { SettingScope } from './settings.js';
import { authCommand } from '../commands/auth.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
getAuthTypeFromEnv,
@ -398,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:
@ -702,9 +704,15 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const debugMode = isDebugMode(argv);
// Set runtime output directory from settings (env var QWEN_RUNTIME_DIR
// is auto-detected inside getRuntimeBaseDir() at each call site).
// Pass cwd so that relative paths like ".qwen" resolve per-project.
Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd);
const ideMode = settings.ide?.enabled ?? false;
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
@ -831,64 +839,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) {
@ -981,9 +1031,31 @@ export async function loadCliConfig(
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,
@ -1102,16 +1174,3 @@ export async function loadCliConfig(
return config;
}
function mergeExcludeTools(
settings: Settings,
extraExcludes?: string[] | undefined,
cliExcludeTools?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(cliExcludeTools || []),
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
return [...allExcludeTools];
}

View file

@ -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'];

View file

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

View file

@ -864,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',
@ -923,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,
},
@ -1213,6 +1263,17 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for the bug report command.',
showInDialog: false,
},
runtimeOutputDir: {
type: 'string',
label: 'Runtime Output Directory',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' +
'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.',
showInDialog: false,
},
tavilyApiKey: {
type: 'string',
label: 'Tavily API Key (Deprecated)',

View file

@ -326,8 +326,15 @@ export async function main() {
}
}
// Handle --resume without a session ID by showing the session picker
// Handle --resume without a session ID by showing the session picker.
// Set the runtime output dir early so the picker can find sessions stored
// under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will
// be called again inside loadCliConfig).
if (argv.resume === '') {
Storage.setRuntimeBaseDir(
settings.merged.advanced?.runtimeOutputDir,
process.cwd(),
);
const selectedSessionId = await showResumeSessionPicker();
if (!selectedSessionId) {
// User cancelled or no sessions available
@ -351,6 +358,7 @@ export async function main() {
argv,
process.cwd(),
argv.extensions,
settings,
);
// Register cleanup for MCP clients as early as possible

View file

@ -1046,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)',
@ -1214,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

View file

@ -1102,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)',
@ -1266,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

View file

@ -785,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)',
@ -905,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}} 個のファイルを開いています',

View file

@ -1053,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)',
@ -1219,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

View file

@ -978,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)',
@ -1142,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}}',
// ============================================================================
// Строка состояния

View file

@ -1043,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)',
@ -1196,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

View file

@ -10,7 +10,7 @@ import {
type Config,
type ServerGeminiStreamEvent,
type ToolCallRequestInfo,
type TaskResultDisplay,
type AgentResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
import type {
@ -144,7 +144,7 @@ class TestJsonOutputAdapter extends BaseJsonOutputAdapter {
exposeCreateSubagentToolUseBlock(
state: MessageState,
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
parentToolUseId: string,
) {
return this.createSubagentToolUseBlock(state, toolCall, parentToolUseId);
@ -1314,7 +1314,7 @@ describe('BaseJsonOutputAdapter', () => {
it('should process subagent tool call', () => {
const parentToolUseId = 'parent-tool-1';
adapter.startSubagentAssistantMessage(parentToolUseId);
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
callId: 'tool-1',
name: 'test_tool',
args: { param: 'value' },
@ -1346,7 +1346,7 @@ describe('BaseJsonOutputAdapter', () => {
const state = adapter.exposeGetMessageState(parentToolUseId);
adapter.exposeAppendText(state, 'Text', parentToolUseId);
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
callId: 'tool-1',
name: 'test_tool',
args: {},
@ -1367,7 +1367,7 @@ describe('BaseJsonOutputAdapter', () => {
it('should create tool_use block for subagent', () => {
const state = adapter.exposeCreateMessageState();
adapter.startAssistantMessage();
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
callId: 'tool-1',
name: 'test_tool',
args: { param: 'value' },

View file

@ -11,7 +11,7 @@ import type {
ToolCallResponseInfo,
SessionMetrics,
ServerGeminiStreamEvent,
TaskResultDisplay,
AgentResultDisplay,
McpToolProgressData,
} from '@qwen-code/qwen-code-core';
import {
@ -110,7 +110,7 @@ export interface JsonOutputAdapterInterface extends MessageEmitter {
startSubagentAssistantMessage?(parentToolUseId: string): void;
processSubagentToolCall?(
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
parentToolUseId: string,
): void;
finalizeSubagentAssistantMessage?(
@ -693,7 +693,7 @@ export abstract class BaseJsonOutputAdapter {
* @param parentToolUseId - Parent tool use ID
*/
processSubagentToolCall(
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
parentToolUseId: string,
): void {
const state = this.getMessageState(parentToolUseId);
@ -744,7 +744,7 @@ export abstract class BaseJsonOutputAdapter {
protected processSubagentToolUseBlock(
state: MessageState,
index: number,
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
parentToolUseId: string,
): void {
// Emit tool_use block creation event (with empty input)
@ -937,7 +937,7 @@ export abstract class BaseJsonOutputAdapter {
*/
protected createSubagentToolUseBlock(
state: MessageState,
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
_parentToolUseId: string,
): { block: ToolUseBlock; index: number } {
const index = state.blocks.length;

View file

@ -43,7 +43,7 @@ import {
extractPartsFromUserMessage,
buildSystemMessage,
createToolProgressHandler,
createTaskToolProgressHandler,
createAgentToolProgressHandler,
computeUsageFromMetrics,
} from './utils/nonInteractiveHelpers.js';
@ -320,12 +320,12 @@ export async function runNonInteractive(
: undefined;
// Build outputUpdateHandler for this tool call.
// Task tool has its own complex handler (subagent messages).
// Agent tool has its own complex handler (subagent messages).
// All other tools with canUpdateOutput=true (e.g., MCP tools)
// get a generic handler that emits progress via the adapter.
const isTaskTool = finalRequestInfo.name === 'task';
const { handler: outputUpdateHandler } = isTaskTool
? createTaskToolProgressHandler(
const isAgentTool = finalRequestInfo.name === 'agent';
const { handler: outputUpdateHandler } = isAgentTool
? createAgentToolProgressHandler(
config,
finalRequestInfo.callId,
adapter,

View file

@ -37,12 +37,22 @@ 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,
},
};
@ -174,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 () => {

View file

@ -31,6 +31,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';
@ -84,7 +85,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
mcpCommand,
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
permissionsCommand,
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,

View file

@ -0,0 +1,91 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import path from 'path';
import fs from 'fs/promises';
import { Storage, type Config } from '@qwen-code/qwen-code-core';
import { StaticInsightGenerator } from './StaticInsightGenerator.js';
vi.mock('fs/promises', () => ({
default: {
mkdir: vi.fn(),
access: vi.fn(),
writeFile: vi.fn(),
unlink: vi.fn(),
symlink: vi.fn(),
copyFile: vi.fn(),
},
}));
describe('StaticInsightGenerator', () => {
const mockedFs = vi.mocked(fs);
const mockConfig = {} as Config;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-05T12:34:56.000Z'));
Storage.setRuntimeBaseDir(path.resolve('runtime-output'));
vi.clearAllMocks();
mockedFs.mkdir.mockResolvedValue(undefined);
mockedFs.access.mockRejectedValue(new Error('not found'));
mockedFs.writeFile.mockResolvedValue(undefined);
mockedFs.unlink.mockRejectedValue(new Error('not found'));
mockedFs.symlink.mockResolvedValue(undefined);
mockedFs.copyFile.mockResolvedValue(undefined);
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
vi.useRealTimers();
vi.restoreAllMocks();
});
it('writes insights under runtime output directory', async () => {
const generator = new StaticInsightGenerator(mockConfig);
const generateInsights = vi.fn().mockResolvedValue({});
const renderInsightHTML = vi.fn().mockResolvedValue('<html>ok</html>');
(
generator as unknown as {
dataProcessor: { generateInsights: typeof generateInsights };
}
).dataProcessor = { generateInsights };
(
generator as unknown as {
templateRenderer: { renderInsightHTML: typeof renderInsightHTML };
}
).templateRenderer = { renderInsightHTML };
const projectsDir = path.resolve(
'workspace',
'project-a',
'.qwen',
'projects',
);
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
const facetsDir = path.join(outputDir, 'facets');
const expectedOutputPath = path.join(outputDir, 'insight-2026-03-05.html');
const outputPath = await generator.generateStaticInsight(projectsDir);
expect(mockedFs.mkdir).toHaveBeenCalledWith(outputDir, { recursive: true });
expect(mockedFs.mkdir).toHaveBeenCalledWith(facetsDir, { recursive: true });
expect(generateInsights).toHaveBeenCalledWith(
projectsDir,
facetsDir,
undefined,
);
expect(renderInsightHTML).toHaveBeenCalledWith({});
expect(mockedFs.writeFile).toHaveBeenCalledWith(
expectedOutputPath,
'<html>ok</html>',
'utf-8',
);
expect(outputPath).toBe(expectedOutputPath);
});
});

View file

@ -6,7 +6,6 @@
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { DataProcessor } from './DataProcessor.js';
import { TemplateRenderer } from './TemplateRenderer.js';
import type {
@ -14,7 +13,7 @@ import type {
InsightProgressCallback,
} from '../types/StaticInsightTypes.js';
import { updateSymlink, type Config } from '@qwen-code/qwen-code-core';
import { updateSymlink, Storage, type Config } from '@qwen-code/qwen-code-core';
export class StaticInsightGenerator {
private dataProcessor: DataProcessor;
@ -27,7 +26,7 @@ export class StaticInsightGenerator {
// Ensure the output directory exists
private async ensureOutputDirectory(): Promise<string> {
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
await fs.mkdir(outputDir, { recursive: true });
return outputDir;
}

View file

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

View file

@ -7,14 +7,12 @@
import {
ApprovalMode,
checkCommandPermissions,
doesToolInvocationMatch,
escapeShellArg,
getShellConfiguration,
ShellExecutionService,
flatMapTextParts,
checkArgumentSafety,
} 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';
@ -136,15 +134,12 @@ export class ShellProcessor implements IPromptProcessor {
// Security check on the final, escaped command string.
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
checkCommandPermissions(command, config, sessionShellAllowlist);
const allowedTools = config.getAllowedTools() || [];
const invocation = {
params: { command },
} as AnyToolInvocation;
const isAllowedBySettings = doesToolInvocationMatch(
'run_shell_command',
invocation,
allowedTools,
);
// Determine if this command is explicitly auto-approved via PermissionManager
const pm = config.getPermissionManager?.();
const isAllowedBySettings = pm
? pm.isCommandAllowed(command) === 'allow'
: false;
if (!allAllowed) {
if (isHardDenial) {

View file

@ -268,7 +268,7 @@ describe('AppContainer State Management', () => {
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
// Mock GeminiClient to prevent unhandled errors from TaskTool.refreshSubagents
// Mock GeminiClient to prevent unhandled errors from AgentTool.refreshSubagents
const mockGeminiClient: Partial<GeminiClient> = {
initialize: vi.fn().mockResolvedValue(undefined),
setTools: vi.fn().mockResolvedValue(undefined),
@ -278,7 +278,7 @@ describe('AppContainer State Management', () => {
mockGeminiClient as GeminiClient,
);
// Mock SubagentManager to prevent errors during TaskTool initialization
// Mock SubagentManager to prevent errors during AgentTool initialization
const mockSubagentManager: Partial<SubagentManager> = {
listSubagents: vi.fn().mockResolvedValue([]),
addChangeListener: vi.fn(),

View file

@ -240,6 +240,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),
@ -550,6 +554,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openTrustDialog,
openArenaDialog,
openPermissionsDialog,
openApprovalModeDialog,
@ -578,6 +583,7 @@ export const AppContainer = (props: AppContainerProps) => {
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
openTrustDialog,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
@ -1383,6 +1389,7 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
@ -1434,6 +1441,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
@ -1530,6 +1538,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
@ -1633,6 +1642,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1685,6 +1695,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

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

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
import path from 'path';
import open from 'open';
import { Storage } from '@qwen-code/qwen-code-core';
import { insightCommand } from './insightCommand.js';
import type { CommandContext } from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
const mockGenerateStaticInsight = vi.fn();
vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({
StaticInsightGenerator: vi.fn(() => ({
generateStaticInsight: mockGenerateStaticInsight,
})),
}));
vi.mock('open', () => ({
default: vi.fn(),
}));
describe('insightCommand', () => {
let mockContext: CommandContext;
beforeEach(() => {
Storage.setRuntimeBaseDir(path.resolve('runtime-output'));
mockGenerateStaticInsight.mockResolvedValue(
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
);
vi.mocked(open).mockResolvedValue(undefined as never);
mockContext = createMockCommandContext({
services: {
config: {} as CommandContext['services']['config'],
},
ui: {
addItem: vi.fn(),
setPendingItem: vi.fn(),
setDebugMessage: vi.fn(),
},
} as unknown as CommandContext);
});
afterEach(() => {
Storage.setRuntimeBaseDir(null);
vi.restoreAllMocks();
});
it('uses runtime base dir to locate projects directory', async () => {
if (!insightCommand.action) {
throw new Error('insight command must have action');
}
await insightCommand.action(mockContext, '');
expect(mockGenerateStaticInsight).toHaveBeenCalledWith(
path.join(Storage.getRuntimeBaseDir(), 'projects'),
expect.any(Function),
);
});
});

View file

@ -10,9 +10,8 @@ import { MessageType } from '../types.js';
import type { HistoryItemInsightProgress } from '../types.js';
import { t } from '../../i18n/index.js';
import { join } from 'path';
import os from 'os';
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core';
import open from 'open';
const logger = createDebugLogger('DataProcessor');
@ -29,7 +28,7 @@ export const insightCommand: SlashCommand = {
try {
context.ui.setDebugMessage(t('Generating insights...'));
const projectsDir = join(os.homedir(), '.qwen', 'projects');
const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects');
if (!context.services.config) {
throw new Error('Config service is not available');
}

View file

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

View file

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

View file

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

View file

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

View file

@ -150,6 +150,7 @@ export interface OpenDialogActionReturn {
| 'model'
| 'subagent_create'
| 'subagent_list'
| 'trust'
| 'permissions'
| 'approval-mode'
| 'resume'

View file

@ -18,7 +18,8 @@ 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';
@ -314,15 +315,16 @@ export const DialogManager = ({
);
}
}
if (uiState.isPermissionsDialogOpen) {
if (uiState.isTrustDialogOpen) {
return (
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
/>
<TrustDialog onExit={uiActions.closeTrustDialog} addItem={addItem} />
);
}
if (uiState.isPermissionsDialogOpen) {
return <PermissionsDialog onExit={uiActions.closePermissionsDialog} />;
}
if (uiState.isSubagentCreateDialogOpen) {
return (
<AgentCreationWizard

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { TodoDisplay } from '../TodoDisplay.js';
import type {
TodoResultDisplay,
TaskResultDisplay,
AgentResultDisplay,
PlanResultDisplay,
AnsiOutput,
Config,
@ -50,7 +50,7 @@ type DisplayRendererResult =
| { type: 'plan'; data: PlanResultDisplay }
| { type: 'string'; data: string }
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
| { type: 'task'; data: TaskResultDisplay }
| { type: 'task'; data: AgentResultDisplay }
| { type: 'ansi'; data: AnsiOutput };
/**
@ -98,7 +98,7 @@ const useResultDisplayRenderer = (
) {
return {
type: 'task',
data: resultDisplay as TaskResultDisplay,
data: resultDisplay as AgentResultDisplay,
};
}
@ -169,7 +169,7 @@ const PlanResultRenderer: React.FC<{
* Component to render subagent execution results
*/
const SubagentExecutionRenderer: React.FC<{
data: TaskResultDisplay;
data: AgentResultDisplay;
availableHeight?: number;
childWidth: number;
config: Config;

View file

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

View file

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

View file

@ -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) {

View file

@ -4,7 +4,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
"▲
1. Foo Title
This is Foo.
2. Bar Title
2. Bar Title
This is Bar.
3. Baz Title
This is Baz.
@ -12,7 +12,7 @@ exports[`DescriptiveRadioButtonSelect > should render correctly with custom prop
`;
exports[`DescriptiveRadioButtonSelect > should render correctly with default props 1`] = `
" Foo Title
" Foo Title
This is Foo.
Bar Title
This is Bar.

View file

@ -7,7 +7,7 @@
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
TaskResultDisplay,
AgentResultDisplay,
AgentStatsSummary,
Config,
} from '@qwen-code/qwen-code-core';
@ -20,7 +20,7 @@ import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.
export type DisplayMode = 'compact' | 'default' | 'verbose';
export interface AgentExecutionDisplayProps {
data: TaskResultDisplay;
data: AgentResultDisplay;
availableHeight?: number;
childWidth: number;
config: Config;
@ -28,7 +28,7 @@ export interface AgentExecutionDisplayProps {
const getStatusColor = (
status:
| TaskResultDisplay['status']
| AgentResultDisplay['status']
| 'executing'
| 'success'
| 'awaiting_approval',
@ -50,7 +50,7 @@ const getStatusColor = (
}
};
const getStatusText = (status: TaskResultDisplay['status']) => {
const getStatusText = (status: AgentResultDisplay['status']) => {
switch (status) {
case 'running':
return 'Running';
@ -301,7 +301,7 @@ const TaskPromptSection: React.FC<{
* Status dot component with similar height as text
*/
const StatusDot: React.FC<{
status: TaskResultDisplay['status'];
status: AgentResultDisplay['status'];
}> = ({ status }) => (
<Box marginLeft={1} marginRight={1}>
<Text color={getStatusColor(status)}></Text>
@ -312,7 +312,7 @@ const StatusDot: React.FC<{
* Status indicator component
*/
const StatusIndicator: React.FC<{
status: TaskResultDisplay['status'];
status: AgentResultDisplay['status'];
}> = ({ status }) => {
const color = getStatusColor(status);
const text = getStatusText(status);
@ -323,7 +323,7 @@ const StatusIndicator: React.FC<{
* Tool calls list - format consistent with ToolInfo in ToolMessage.tsx
*/
const ToolCallsList: React.FC<{
toolCalls: TaskResultDisplay['toolCalls'];
toolCalls: AgentResultDisplay['toolCalls'];
displayMode: DisplayMode;
}> = ({ toolCalls, displayMode }) => {
const calls = toolCalls || [];
@ -435,7 +435,7 @@ const ToolCallItem: React.FC<{
* Execution summary details component
*/
const ExecutionSummaryDetails: React.FC<{
data: TaskResultDisplay;
data: AgentResultDisplay;
displayMode: DisplayMode;
}> = ({ data, displayMode: _displayMode }) => {
const stats = data.executionSummary;
@ -505,7 +505,7 @@ const ToolUsageStats: React.FC<{
* Results section for completed executions - matches the clean layout from the image
*/
const ResultsSection: React.FC<{
data: TaskResultDisplay;
data: AgentResultDisplay;
displayMode: DisplayMode;
}> = ({ data, displayMode }) => (
<Box flexDirection="column" gap={1}>

View file

@ -59,6 +59,7 @@ export interface UIActions {
closeArenaDialog: () => void;
handleArenaModelsSelected?: (models: string[]) => void;
dismissCodingPlanUpdate: () => void;
closeTrustDialog: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;
vimHandleInput: (key: Key) => boolean;

View file

@ -53,6 +53,7 @@ export interface UIState {
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
isTrustDialogOpen: boolean;
activeArenaDialog: ArenaDialogType;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;

View file

@ -156,6 +156,7 @@ describe('useSlashCommandProcessor', () => {
openEditorDialog: vi.fn(),
openSettingsDialog: vi.fn(),
openModelDialog: mockOpenModelDialog,
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),
@ -929,6 +930,7 @@ describe('useSlashCommandProcessor', () => {
openEditorDialog: vi.fn(),
openSettingsDialog: vi.fn(),
openModelDialog: vi.fn(),
openTrustDialog: vi.fn(),
openPermissionsDialog: vi.fn(),
openApprovalModeDialog: vi.fn(),
openResumeDialog: vi.fn(),

View file

@ -72,6 +72,7 @@ interface SlashCommandProcessorActions {
openEditorDialog: () => void;
openSettingsDialog: () => void;
openModelDialog: () => void;
openTrustDialog: () => void;
openPermissionsDialog: () => void;
openApprovalModeDialog: () => void;
openResumeDialog: () => void;
@ -485,6 +486,9 @@ export const useSlashCommandProcessor = (
case 'model':
actions.openModelDialog();
return { type: 'handled' };
case 'trust':
actions.openTrustDialog();
return { type: 'handled' };
case 'permissions':
actions.openPermissionsDialog();
return { type: 'handled' };

View file

@ -59,7 +59,7 @@ const mockConfig = {
},
getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
getAllowedTools: vi.fn(() => []),
getPermissionsAllow: vi.fn(() => []),
getContentGeneratorConfig: () => ({
model: 'test-model',
authType: 'gemini',
@ -83,24 +83,14 @@ const mockTool = new MockTool({
name: 'mockTool',
displayName: 'Mock Tool',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const mockToolWithLiveOutput = new MockTool({
name: 'mockToolWithLiveOutput',
displayName: 'Mock Tool With Live Output',
description: 'A mock tool for testing',
params: {},
isOutputMarkdown: true,
canUpdateOutput: true,
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
let mockOnUserConfirmForToolConfirmation: Mock;
const mockToolRequiresConfirmation = new MockTool({
name: 'mockToolRequiresConfirmation',
displayName: 'Mock Tool Requires Confirmation',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
getDefaultPermission: () => Promise.resolve('ask' as any),
getConfirmationDetails: vi.fn(),
});
describe('useReactToolScheduler in YOLO Mode', () => {
@ -112,7 +102,7 @@ describe('useReactToolScheduler in YOLO Mode', () => {
setPendingHistoryItem = vi.fn();
mockToolRegistry.getTool.mockClear();
(mockToolRequiresConfirmation.execute as Mock).mockClear();
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
// IMPORTANT: Enable YOLO mode for this test suite
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
@ -218,17 +208,14 @@ describe('useReactToolScheduler', () => {
mockToolRegistry.getTool.mockClear();
(mockTool.execute as Mock).mockClear();
(mockTool.shouldConfirmExecute as Mock).mockClear();
(mockToolWithLiveOutput.execute as Mock).mockClear();
(mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.execute as Mock).mockClear();
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
mockOnUserConfirmForToolConfirmation = vi.fn();
(
mockToolRequiresConfirmation.shouldConfirmExecute as Mock
mockToolRequiresConfirmation.getConfirmationDetails as Mock
).mockImplementation(
async (): Promise<ToolCallConfirmationDetails | null> =>
async (): Promise<ToolCallConfirmationDetails> =>
({
onConfirm: mockOnUserConfirmForToolConfirmation,
fileName: 'mockToolRequiresConfirmation.ts',
@ -267,7 +254,6 @@ describe('useReactToolScheduler', () => {
llmContent: 'Tool output',
returnDisplay: 'Formatted tool output',
} as ToolResult);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const { result } = renderScheduler();
const schedule = result.current[1];
@ -352,10 +338,11 @@ describe('useReactToolScheduler', () => {
expect(result.current[0]).toEqual([]);
});
it('should handle error during shouldConfirmExecute', async () => {
it('should handle error during getDefaultPermission', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
const confirmError = new Error('Confirmation check failed');
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
const originalGetDefaultPermission = mockTool.getDefaultPermission;
mockTool.getDefaultPermission = () => Promise.reject(confirmError);
const { result } = renderScheduler();
const schedule = result.current[1];
@ -385,11 +372,11 @@ describe('useReactToolScheduler', () => {
}),
]);
expect(result.current[0]).toEqual([]);
mockTool.getDefaultPermission = originalGetDefaultPermission;
});
it('should handle error during execute', async () => {
mockToolRegistry.getTool.mockReturnValue(mockTool);
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
const execError = new Error('Execution failed');
(mockTool.execute as Mock).mockRejectedValue(execError);
@ -532,7 +519,6 @@ describe('mapToDisplay', () => {
name: 'testTool',
displayName: 'Test Tool Display',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const baseResponse: ToolCallResponseInfo = {
@ -767,7 +753,6 @@ describe('mapToDisplay', () => {
displayName: baseTool.displayName,
isOutputMarkdown: true,
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const toolCall2: ToolCall = {
request: { ...baseRequest, callId: 'call2' },

View file

@ -16,7 +16,7 @@ import {
type Mock,
} from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePermissionsModifyTrust } from './usePermissionsModifyTrust.js';
import { useTrustModify } from './useTrustModify.js';
import { TrustLevel } from '../../config/trustedFolders.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { LoadedTrustedFolders } from '../../config/trustedFolders.js';
@ -46,7 +46,7 @@ vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: mockedUseSettings,
}));
describe('usePermissionsModifyTrust', () => {
describe('useTrustModify', () => {
let mockOnExit: Mock;
let mockAddItem: Mock;
@ -84,7 +84,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
expect(result.current.currentTrustLevel).toBe(TrustLevel.TRUST_FOLDER);
@ -101,7 +101,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
expect(result.current.isInheritedTrustFromParent).toBe(true);
@ -118,7 +118,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
expect(result.current.isInheritedTrustFromIde).toBe(true);
@ -137,7 +137,7 @@ describe('usePermissionsModifyTrust', () => {
.mockReturnValueOnce({ isTrusted: true, source: 'file' });
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
act(() => {
@ -161,7 +161,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
act(() => {
@ -188,7 +188,7 @@ describe('usePermissionsModifyTrust', () => {
.mockReturnValueOnce({ isTrusted: true, source: 'file' });
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
act(() => {
@ -218,7 +218,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
act(() => {
@ -245,7 +245,7 @@ describe('usePermissionsModifyTrust', () => {
});
const { result } = renderHook(() =>
usePermissionsModifyTrust(mockOnExit, mockAddItem),
useTrustModify(mockOnExit, mockAddItem),
);
act(() => {

View file

@ -42,7 +42,7 @@ function getInitialTrustState(
};
}
export const usePermissionsModifyTrust = (
export const useTrustModify = (
onExit: () => void,
addItem: UseHistoryManagerReturn['addItem'],
) => {

View file

@ -181,10 +181,7 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats {
let filePath: string;
if (typeof display.fileName === 'string') {
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
filePath =
(args?.['file_path'] as string) ||
(args?.['absolute_path'] as string) ||
display.fileName;
filePath = (args?.['file_path'] as string) || display.fileName;
} else {
// Fallback if fileName is not a string
filePath = 'unknown';

View file

@ -100,6 +100,10 @@ function formatToolDescription(
const invocation = tool.build(args);
return invocation.getDescription();
} catch {
// Fallback: use the description arg directly if available
if (typeof args['description'] === 'string') {
return args['description'];
}
return '';
}
}

View file

@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
import type {
Config,
SessionMetrics,
TaskResultDisplay,
AgentResultDisplay,
ToolCallResponseInfo,
} from '@qwen-code/qwen-code-core';
import {
@ -30,7 +30,7 @@ import {
computeUsageFromMetrics,
buildSystemMessage,
createToolProgressHandler,
createTaskToolProgressHandler,
createAgentToolProgressHandler,
functionResponsePartsToString,
toolResultContent,
} from './nonInteractiveHelpers.js';
@ -731,7 +731,7 @@ describe('createToolProgressHandler', () => {
});
});
describe('createTaskToolProgressHandler', () => {
describe('createAgentToolProgressHandler', () => {
let mockAdapter: JsonOutputAdapterInterface;
let mockConfig: Config;
@ -751,13 +751,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should create handler that processes task tool calls', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -786,13 +786,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should emit tool_result when tool call completes', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -825,13 +825,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should not duplicate tool_use emissions', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -855,13 +855,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should not duplicate tool_result emissions', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -886,14 +886,14 @@ describe('createTaskToolProgressHandler', () => {
});
it('should handle status transitions from executing to completed', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
// First: executing state
const executingDisplay: TaskResultDisplay = {
const executingDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -910,7 +910,7 @@ describe('createTaskToolProgressHandler', () => {
};
// Second: completed state
const completedDisplay: TaskResultDisplay = {
const completedDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -935,13 +935,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should emit error result for failed task status', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const runningDisplay: TaskResultDisplay = {
const runningDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -950,7 +950,7 @@ describe('createTaskToolProgressHandler', () => {
toolCalls: [],
};
const failedDisplay: TaskResultDisplay = {
const failedDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -971,13 +971,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should emit error result for cancelled task status', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const runningDisplay: TaskResultDisplay = {
const runningDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -986,7 +986,7 @@ describe('createTaskToolProgressHandler', () => {
toolCalls: [],
};
const cancelledDisplay: TaskResultDisplay = {
const cancelledDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -1006,7 +1006,7 @@ describe('createTaskToolProgressHandler', () => {
});
it('should not process non-task-execution displays', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
@ -1017,20 +1017,20 @@ describe('createTaskToolProgressHandler', () => {
content: 'some content',
};
handler('call-id', nonTaskDisplay as unknown as TaskResultDisplay);
handler('call-id', nonTaskDisplay as unknown as AgentResultDisplay);
expect(mockAdapter.processSubagentToolCall).not.toHaveBeenCalled();
expect(mockAdapter.emitToolResult).not.toHaveBeenCalled();
});
it('should handle tool calls with failed status', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -1061,13 +1061,13 @@ describe('createTaskToolProgressHandler', () => {
});
it('should handle tool calls without result content', () => {
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
mockAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',
@ -1096,13 +1096,13 @@ describe('createTaskToolProgressHandler', () => {
emitToolResult: vi.fn(),
} as unknown as JsonOutputAdapterInterface;
const { handler } = createTaskToolProgressHandler(
const { handler } = createAgentToolProgressHandler(
mockConfig,
'parent-tool-id',
limitedAdapter,
);
const taskDisplay: TaskResultDisplay = {
const taskDisplay: AgentResultDisplay = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'Test task',

View file

@ -7,7 +7,7 @@
import type {
Config,
ToolResultDisplay,
TaskResultDisplay,
AgentResultDisplay,
OutputUpdateHandler,
ToolCallRequestInfo,
ToolCallResponseInfo,
@ -335,25 +335,25 @@ export function createToolProgressHandler(
}
/**
* Creates an output update handler specifically for Task tool subagent execution.
* This handler monitors TaskResultDisplay updates and converts them to protocol messages
* Creates an output update handler specifically for Agent tool subagent execution.
* This handler monitors AgentResultDisplay updates and converts them to protocol messages
* using the unified adapter's subagent APIs. All emitted messages will have parent_tool_use_id set to
* the task tool's callId.
* the agent tool's callId.
*
* @param config - Config instance for getting output format
* @param taskToolCallId - The task tool's callId to use as parent_tool_use_id for all subagent messages
* @param agentToolCallId - The agent tool's callId to use as parent_tool_use_id for all subagent messages
* @param adapter - The unified adapter instance (JsonOutputAdapter or StreamJsonOutputAdapter)
* @returns An object containing the output update handler
*/
export function createTaskToolProgressHandler(
export function createAgentToolProgressHandler(
config: Config,
taskToolCallId: string,
agentToolCallId: string,
adapter: JsonOutputAdapterInterface,
): {
handler: OutputUpdateHandler;
} {
// Track previous TaskResultDisplay states per tool call to detect changes
const previousTaskStates = new Map<string, TaskResultDisplay>();
// Track previous AgentResultDisplay states per tool call to detect changes
const previousTaskStates = new Map<string, AgentResultDisplay>();
// Track which tool call IDs have already emitted tool_use to prevent duplicates
const emittedToolUseIds = new Set<string>();
// Track which tool call IDs have already emitted tool_result to prevent duplicates
@ -366,7 +366,7 @@ export function createTaskToolProgressHandler(
* @returns ToolCallRequestInfo object
*/
const buildRequest = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
): ToolCallRequestInfo => ({
callId: toolCall.callId,
name: toolCall.name,
@ -383,7 +383,7 @@ export function createTaskToolProgressHandler(
* @returns ToolCallResponseInfo object
*/
const buildResponse = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
): ToolCallResponseInfo => ({
callId: toolCall.callId,
error:
@ -403,7 +403,7 @@ export function createTaskToolProgressHandler(
* @returns True if the tool call has result content to emit
*/
const hasResultContent = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
): boolean => {
// Check resultDisplay string
if (
@ -429,14 +429,14 @@ export function createTaskToolProgressHandler(
* @param fallbackStatus - Optional fallback status if toolCall.status should be overridden
*/
const emitToolUseIfNeeded = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
fallbackStatus?: 'executing' | 'awaiting_approval',
): void => {
if (emittedToolUseIds.has(toolCall.callId)) {
return;
}
const toolCallToEmit: NonNullable<TaskResultDisplay['toolCalls']>[number] =
const toolCallToEmit: NonNullable<AgentResultDisplay['toolCalls']>[number] =
fallbackStatus
? {
...toolCall,
@ -449,7 +449,7 @@ export function createTaskToolProgressHandler(
toolCallToEmit.status === 'awaiting_approval'
) {
if (adapter.processSubagentToolCall) {
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
adapter.processSubagentToolCall(toolCallToEmit, agentToolCallId);
emittedToolUseIds.add(toolCall.callId);
}
}
@ -461,7 +461,7 @@ export function createTaskToolProgressHandler(
* @param toolCall - The tool call information
*/
const emitToolResultIfNeeded = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
): void => {
if (emittedToolResultIds.has(toolCall.callId)) {
return;
@ -482,7 +482,7 @@ export function createTaskToolProgressHandler(
'emitToolResult' in adapter &&
typeof adapter.emitToolResult === 'function'
) {
adapter.emitToolResult(request, response, taskToolCallId);
adapter.emitToolResult(request, response, agentToolCallId);
} else {
adapter.emitToolResult(request, response);
}
@ -495,8 +495,8 @@ export function createTaskToolProgressHandler(
* @param previousCall - The previous state of the tool call (if any)
*/
const processToolCall = (
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
previousCall?: NonNullable<TaskResultDisplay['toolCalls']>[number],
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
previousCall?: NonNullable<AgentResultDisplay['toolCalls']>[number],
): void => {
const isCompleted =
toolCall.status === 'success' || toolCall.status === 'failed';
@ -531,14 +531,14 @@ export function createTaskToolProgressHandler(
callId: string,
outputChunk: ToolResultDisplay,
) => {
// Only process TaskResultDisplay (Task tool updates)
// Only process AgentResultDisplay (Task tool updates)
if (
typeof outputChunk === 'object' &&
outputChunk !== null &&
'type' in outputChunk &&
outputChunk.type === 'task_execution'
) {
const taskDisplay = outputChunk as TaskResultDisplay;
const taskDisplay = outputChunk as AgentResultDisplay;
const previous = previousTaskStates.get(callId);
// Only process if adapter supports subagent APIs
@ -585,7 +585,7 @@ export function createTaskToolProgressHandler(
? 'Task was cancelled'
: 'Task execution failed');
// Use subagent adapter's emitSubagentErrorResult method
adapter.emitSubagentErrorResult(errorMessage, 0, taskToolCallId);
adapter.emitSubagentErrorResult(errorMessage, 0, agentToolCallId);
}
}
@ -601,7 +601,7 @@ export function createTaskToolProgressHandler(
// Emit the user message with the correct parent_tool_use_id
adapter.emitUserMessage(
[{ text: taskDisplay.taskPrompt }],
taskToolCallId,
agentToolCallId,
);
}
@ -610,7 +610,7 @@ export function createTaskToolProgressHandler(
}
};
// No longer need to attach adapter to handler - task.ts uses TaskResultDisplay.message instead
// No longer need to attach adapter to handler - task.ts uses AgentResultDisplay.message instead
return {
handler: outputUpdateHandler,