Merge branch 'main' into refactor/task-to-agent-tool

This commit is contained in:
tanzhenxin 2026-03-20 10:23:25 +08:00
commit 9d6aca8efe
160 changed files with 24692 additions and 1942 deletions

View file

@ -34,6 +34,7 @@ import {
TodoWriteTool,
ExitPlanModeTool,
readManyFiles,
ToolNames,
} from '@qwen-code/qwen-code-core';
import { RequestError } from '@agentclientprotocol/sdk';
@ -553,18 +554,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 +573,7 @@ export class Session implements SessionContext {
isPlanMode &&
!isExitPlanModeTool &&
!isAskUserQuestionTool &&
effectiveConfirmationDetails
needsConfirmation
) {
// In plan mode, block any tool that requires confirmation (write operations)
return errorResponse(
@ -584,25 +584,35 @@ export class Session implements SessionContext {
);
}
if (effectiveConfirmationDetails) {
if (defaultPermission === 'deny') {
return errorResponse(
new Error(
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
),
);
}
if (needsConfirmation) {
const confirmationDetails =
await invocation.getConfirmationDetails(abortSignal);
const content: ToolCallContent[] = [];
if (effectiveConfirmationDetails.type === 'edit') {
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: effectiveConfirmationDetails.fileName,
oldText: effectiveConfirmationDetails.originalContent,
newText: effectiveConfirmationDetails.newContent,
path: confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});
}
// Add plan content for exit_plan_mode
if (effectiveConfirmationDetails.type === 'plan') {
if (confirmationDetails.type === 'plan') {
content.push({
type: 'content',
content: {
type: 'text',
text: effectiveConfirmationDetails.plan,
text: confirmationDetails.plan,
},
});
}
@ -612,7 +622,7 @@ export class Session implements SessionContext {
const params: RequestPermissionRequest = {
sessionId: this.sessionId,
options: toPermissionOptions(effectiveConfirmationDetails),
options: toPermissionOptions(confirmationDetails),
toolCall: {
toolCallId: callId,
status: 'pending',
@ -636,7 +646,7 @@ export class Session implements SessionContext {
.nativeEnum(ToolConfirmationOutcome)
.parse(output.outcome.optionId);
await effectiveConfirmationDetails.onConfirm(outcome, {
await confirmationDetails.onConfirm(outcome, {
answers: output.answers,
});
@ -652,6 +662,8 @@ export class Session implements SessionContext {
);
case ToolConfirmationOutcome.ProceedOnce:
case ToolConfirmationOutcome.ProceedAlways:
case ToolConfirmationOutcome.ProceedAlwaysProject:
case ToolConfirmationOutcome.ProceedAlwaysUser:
case ToolConfirmationOutcome.ProceedAlwaysServer:
case ToolConfirmationOutcome.ProceedAlwaysTool:
case ToolConfirmationOutcome.ModifyWithEditor:
@ -1041,8 +1053,13 @@ function toPermissionOptions(
case 'exec':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow ${confirmation.rootCommand}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.rootCommand}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -1050,13 +1067,13 @@ function toPermissionOptions(
case 'mcp':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
name: `Always Allow ${confirmation.serverName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project: ${confirmation.toolName}`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
name: `Always Allow ${confirmation.toolName}`,
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user: ${confirmation.toolName}`,
kind: 'allow_always',
},
...basicPermissionOptions,
@ -1064,8 +1081,13 @@ function toPermissionOptions(
case 'info':
return [
{
optionId: ToolConfirmationOutcome.ProceedAlways,
name: `Always Allow`,
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
name: `Always Allow in project`,
kind: 'allow_always',
},
{
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
name: `Always Allow for user`,
kind: 'allow_always',
},
...basicPermissionOptions,

View file

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

@ -1022,7 +1022,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual([]);
expect(config.getPermissionsDeny()).toEqual([]);
});
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
@ -1031,7 +1031,7 @@ describe('mergeExcludeTools', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual(defaultExcludes);
expect(config.getPermissionsDeny()).toEqual(defaultExcludes);
});
it('should handle settings with excludeTools but no extensions', async () => {
@ -1039,10 +1039,10 @@ describe('mergeExcludeTools', () => {
const argv = await parseArguments();
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
const config = await loadCliConfig(settings, argv, undefined, []);
expect(config.getExcludeTools()).toEqual(
expect(config.getPermissionsDeny()).toEqual(
expect.arrayContaining(['tool1', 'tool2']),
);
expect(config.getExcludeTools()).toHaveLength(2);
expect(config.getPermissionsDeny()).toHaveLength(2);
});
});
@ -1067,7 +1067,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1086,7 +1086,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = {};
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1106,7 +1106,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1123,7 +1123,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1140,7 +1140,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).toContain(EditTool.Name);
expect(excludedTools).toContain(WriteFileTool.Name);
@ -1160,7 +1160,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1180,7 +1180,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1193,7 +1193,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1218,7 +1218,7 @@ describe('Approval mode tool exclusion logic', () => {
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).not.toContain(ShellTool.Name);
expect(excludedTools).not.toContain(EditTool.Name);
expect(excludedTools).not.toContain(WriteFileTool.Name);
@ -1238,7 +1238,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings: Settings = { tools: { exclude: ['custom_tool'] } };
const config = await loadCliConfig(settings, argv, undefined, []);
const excludedTools = config.getExcludeTools();
const excludedTools = config.getPermissionsDeny();
expect(excludedTools).toContain('custom_tool'); // From settings
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
@ -1834,9 +1834,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
@ -1844,9 +1844,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
@ -1854,9 +1854,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '-p', 'test'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).toContain('run_shell_command');
expect(config.getExcludeTools()).toContain('edit');
expect(config.getExcludeTools()).toContain('write_file');
expect(config.getPermissionsDeny()).toContain('run_shell_command');
expect(config.getPermissionsDeny()).toContain('edit');
expect(config.getPermissionsDeny()).toContain('write_file');
});
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
@ -1864,9 +1864,9 @@ describe('loadCliConfig tool exclusions', () => {
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
const argv = await parseArguments();
const config = await loadCliConfig({}, argv, undefined, []);
expect(config.getExcludeTools()).not.toContain('run_shell_command');
expect(config.getExcludeTools()).not.toContain('replace');
expect(config.getExcludeTools()).not.toContain('write_file');
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
expect(config.getPermissionsDeny()).not.toContain('replace');
expect(config.getPermissionsDeny()).not.toContain('write_file');
});
});

View file

@ -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,6 +704,7 @@ export async function loadCliConfig(
argv: CliArgs,
cwd: string = process.cwd(),
overrideExtensions?: string[],
loadedSettings?: LoadedSettings,
): Promise<Config> {
const debugMode = isDebugMode(argv);
@ -831,64 +834,106 @@ export async function loadCliConfig(
// (fallback for edge cases where query/prompt is provided with TEXT output)
interactive = false;
}
// In non-interactive mode, exclude tools that require a prompt.
// However, if stream-json input is used, control can be requested via JSON messages,
// so tools should not be excluded in that case.
const extraExcludes: string[] = [];
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
const resolvedAllowedTools =
argv.allowedTools || settings.tools?.allowed || [];
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
if (resolvedCoreTools.length > 0) {
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
return true;
}
// ── Unified permissions construction ─────────────────────────────────────
// All permission sources are merged here, before constructing Config.
// The resulting three arrays are the single source of truth that Config /
// PermissionManager will use.
//
// Sources (in order of precedence within each list):
// 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings)
// 2. argv.coreTools → allow (allowlist mode: only these tools are available)
// 3. argv.allowedTools → allow (auto-approve these tools/commands)
// 4. argv.excludeTools → deny (block these tools completely)
// 5. Non-interactive mode exclusions → deny (unless explicitly allowed above)
// Start from settings-level rules.
// Read from both new `permissions` and legacy `tools` paths for compatibility.
// Note: settings.tools.core / argv.coreTools are intentionally NOT merged into
// mergedAllow — they have whitelist semantics (only listed tools are registered),
// not auto-approve semantics. They are passed via the `coreTools` Config param
// and handled by PermissionManager.coreToolsAllowList.
const resolvedCoreTools: string[] = [
...(argv.coreTools ?? []),
...(settings.tools?.core ?? []),
];
const mergedAllow: string[] = [
...(settings.permissions?.allow ?? []),
...(settings.tools?.allowed ?? []),
];
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
const mergedDeny: string[] = [
...(settings.permissions?.deny ?? []),
...(settings.tools?.exclude ?? []),
];
// argv.allowedTools adds allow rules (auto-approve).
for (const t of argv.allowedTools ?? []) {
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
}
// argv.excludeTools adds deny rules.
for (const t of argv.excludeTools ?? []) {
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
}
// Helper: check if a tool is explicitly covered by an allow rule OR by the
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
// to preserve the original behaviour where "ShellTool", "Shell", and
// "run_shell_command" are all accepted as the same tool.
const isExplicitlyAllowed = (toolName: ToolName): boolean => {
const name = toolName as string;
// 1. Check permissions.allow / allowedTools rules.
if (
mergedAllow.some((rule) => {
const openParen = rule.indexOf('(');
const ruleName =
openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim();
return ruleName === name;
})
) {
return true;
}
if (resolvedAllowedTools.length > 0) {
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
return true;
}
// 2. Check coreTools whitelist (with alias matching).
// If coreTools is non-empty and explicitly includes this tool, it is
// considered allowed for non-interactive mode exclusion purposes.
if (resolvedCoreTools.length > 0) {
return isToolEnabled(toolName, resolvedCoreTools, []);
}
return false;
};
const excludeUnlessExplicit = (toolName: ToolName): void => {
if (!isExplicitlyEnabled(toolName)) {
extraExcludes.push(toolName);
}
};
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
// In non-interactive mode, tools that require a user prompt are denied unless
// the caller has explicitly allowed them. Stream-JSON input is excluded from
// this logic because approval can be sent programmatically via JSON messages.
const isAcpMode = argv.acp || argv.experimentalAcp;
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
const denyUnlessAllowed = (toolName: ToolName): void => {
if (!isExplicitlyAllowed(toolName)) {
const name = toolName as string;
if (!mergedDeny.includes(name)) mergedDeny.push(name);
}
};
switch (approvalMode) {
case ApprovalMode.PLAN:
case ApprovalMode.DEFAULT:
// In default non-interactive mode, all tools that require approval are excluded,
// unless explicitly enabled via coreTools/allowedTools.
excludeUnlessExplicit(ShellTool.Name as ToolName);
excludeUnlessExplicit(EditTool.Name as ToolName);
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
// Deny all write/execute tools unless explicitly allowed.
denyUnlessAllowed(ShellTool.Name as ToolName);
denyUnlessAllowed(EditTool.Name as ToolName);
denyUnlessAllowed(WriteFileTool.Name as ToolName);
break;
case ApprovalMode.AUTO_EDIT:
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
excludeUnlessExplicit(ShellTool.Name as ToolName);
// Only shell requires a prompt in auto-edit mode.
denyUnlessAllowed(ShellTool.Name as ToolName);
break;
case ApprovalMode.YOLO:
// No extra excludes for YOLO mode.
// No extra denials for YOLO mode.
break;
default:
// This should never happen due to validation earlier, but satisfies the linter
break;
}
}
const excludeTools = mergeExcludeTools(
settings,
extraExcludes.length > 0 ? extraExcludes : undefined,
argv.excludeTools,
);
let allowedMcpServers: Set<string> | undefined;
let excludedMcpServers: Set<string> | undefined;
if (argv.allowedMcpServerNames) {
@ -981,9 +1026,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 +1169,3 @@ export async function loadCliConfig(
return config;
}
function mergeExcludeTools(
settings: Settings,
extraExcludes?: string[] | undefined,
cliExcludeTools?: string[] | undefined,
): string[] {
const allExcludeTools = new Set([
...(cliExcludeTools || []),
...(settings.tools?.exclude || []),
...(extraExcludes || []),
]);
return [...allExcludeTools];
}

View file

@ -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,
},
@ -1411,6 +1461,109 @@ const SETTINGS_SCHEMA = {
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
Notification: {
type: 'array',
label: 'Notification Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when notifications are sent.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreToolUse: {
type: 'array',
label: 'Pre Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUse: {
type: 'array',
label: 'Post Tool Use Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute after successful tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PostToolUseFailure: {
type: 'array',
label: 'Post Tool Use Failure Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when tool execution fails. ',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionStart: {
type: 'array',
label: 'Session Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a new session starts or resumes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SessionEnd: {
type: 'array',
label: 'Session End Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute when a session ends.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PreCompact: {
type: 'array',
label: 'Pre Compact Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description: 'Hooks that execute before conversation compaction.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStart: {
type: 'array',
label: 'Subagent Start Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a subagent (Task tool call) is started.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
SubagentStop: {
type: 'array',
label: 'Subagent Stop Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
PermissionRequest: {
type: 'array',
label: 'Permission Request Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute when a permission dialog is displayed.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
},
},
},

View file

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

View file

@ -351,6 +351,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

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

View file

@ -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';
@ -77,14 +78,15 @@ export class BuiltinCommandLoader implements ICommandLoader {
exportCommand,
extensionsCommand,
helpCommand,
hooksCommand,
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
await ideCommand(),
initCommand,
languageCommand,
mcpCommand,
memoryCommand,
modelCommand,
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
permissionsCommand,
...(this.config?.getFolderTrust() ? [trustCommand] : []),
quitCommand,
restoreCommand(this.config),
resumeCommand,

View file

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

View file

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

View file

@ -39,6 +39,8 @@ import {
getAllGeminiMdFilenames,
ShellExecutionService,
Storage,
SessionEndReason,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
import { validateAuthMethod } from '../config/auth.js';
@ -238,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),
@ -291,7 +297,42 @@ export const AppContainer = (props: AppContainerProps) => {
);
historyManager.loadHistory(historyItems);
}
// Fire SessionStart event after config is initialized
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
const hookSystem = config.getHookSystem();
if (hookSystem) {
hookSystem
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
.then(() => {
debugLogger.debug('SessionStart event completed successfully');
})
.catch((err) => {
debugLogger.warn(`SessionStart hook failed: ${err}`);
});
} else {
debugLogger.debug(
'SessionStart: HookSystem not available, skipping event',
);
}
})();
// Register SessionEnd cleanup for process exit
registerCleanup(async () => {
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
debugLogger.debug('SessionEnd event completed successfully!!!');
} catch (err) {
debugLogger.error(`SessionEnd hook failed: ${err}`);
}
});
registerCleanup(async () => {
const ideClient = await IdeClient.getInstance();
await ideClient.disconnect();
@ -513,6 +554,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openTrustDialog,
openArenaDialog,
openPermissionsDialog,
openApprovalModeDialog,
@ -541,6 +583,7 @@ export const AppContainer = (props: AppContainerProps) => {
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
openTrustDialog,
openPermissionsDialog,
openApprovalModeDialog,
addConfirmUpdateExtensionRequest,
@ -1071,6 +1114,7 @@ export const AppContainer = (props: AppContainerProps) => {
streamingState,
elapsedTime,
settings,
config,
});
// Dialog close functionality
@ -1345,6 +1389,7 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
isTrustDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
@ -1396,6 +1441,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
@ -1492,6 +1538,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
@ -1595,6 +1642,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,
@ -1647,6 +1695,7 @@ export const AppContainer = (props: AppContainerProps) => {
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closeTrustDialog,
closePermissionsDialog,
setShellModeActive,
vimHandleInput,

View file

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

View file

@ -9,6 +9,8 @@ import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
@ -24,6 +26,15 @@ export const clearCommand: SlashCommand = {
const { config } = context.services;
if (config) {
// Fire SessionEnd event before clearing (current session ends)
try {
await config
.getHookSystem()
?.fireSessionEndEvent(SessionEndReason.Clear);
} catch (err) {
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
}
const newSessionId = config.startNewSession();
// Reset UI telemetry metrics for the new session
@ -53,6 +64,18 @@ export const clearCommand: SlashCommand = {
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}
// Fire SessionStart event after clearing (new session starts)
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Clear,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
} else {
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
}

View file

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

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

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

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

@ -11,6 +11,11 @@ import {
AttentionNotificationReason,
} from '../../utils/attentionNotification.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
fireNotificationHook,
NotificationType,
} from '@qwen-code/qwen-code-core';
export const LONG_TASK_NOTIFICATION_THRESHOLD_SECONDS = 20;
@ -19,6 +24,7 @@ interface UseAttentionNotificationsOptions {
streamingState: StreamingState;
elapsedTime: number;
settings: LoadedSettings;
config?: Config;
}
export const useAttentionNotifications = ({
@ -26,10 +32,12 @@ export const useAttentionNotifications = ({
streamingState,
elapsedTime,
settings,
config,
}: UseAttentionNotificationsOptions) => {
const terminalBellEnabled = settings?.merged?.general?.terminalBell ?? true;
const awaitingNotificationSentRef = useRef(false);
const respondingElapsedRef = useRef(0);
const idleNotificationSentRef = useRef(false);
useEffect(() => {
if (
@ -51,6 +59,8 @@ export const useAttentionNotifications = ({
useEffect(() => {
if (streamingState === StreamingState.Responding) {
respondingElapsedRef.current = elapsedTime;
// Reset idle notification flag when responding
idleNotificationSentRef.current = false;
return;
}
@ -65,7 +75,28 @@ export const useAttentionNotifications = ({
}
// Reset tracking for next task
respondingElapsedRef.current = 0;
// Fire idle_prompt notification hook when entering idle state
if (config && !idleNotificationSentRef.current) {
const messageBus = config.getMessageBus();
const hooksEnabled = config.getEnableHooks();
if (hooksEnabled && messageBus) {
fireNotificationHook(
messageBus,
'Qwen Code is waiting for your input',
NotificationType.IdlePrompt,
'Waiting for input',
).catch(() => {
// Silently ignore errors - fireNotificationHook has internal error handling
// and notification hooks should not block the idle flow
});
}
idleNotificationSentRef.current = true;
}
return;
}
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled]);
// Reset idle notification flag when in WaitingForConfirmation state
idleNotificationSentRef.current = false;
}, [streamingState, elapsedTime, isFocused, terminalBellEnabled, config]);
};

View file

@ -142,6 +142,11 @@ describe('useResumeCommand', () => {
getTargetDir: () => '/tmp',
getGeminiClient: () => geminiClient,
startNewSession: vi.fn(),
getDebugLogger: () => ({
warn: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
} as unknown as import('@qwen-code/qwen-code-core').Config;
const { result } = renderHook(() =>

View file

@ -5,7 +5,11 @@
*/
import { useState, useCallback } from 'react';
import { SessionService, type Config } from '@qwen-code/qwen-code-core';
import {
SessionService,
type Config,
SessionStartSource,
} from '@qwen-code/qwen-code-core';
import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@ -67,6 +71,18 @@ export function useResumeCommand(
config.startNewSession(sessionId, sessionData);
await config.getGeminiClient()?.initialize?.();
// Fire SessionStart event after resuming session
try {
await config
.getHookSystem()
?.fireSessionStartEvent(
SessionStartSource.Resume,
config.getModel() ?? '',
);
} catch (err) {
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
}
// Refresh terminal UI.
remount?.();
},

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',
@ -68,30 +68,29 @@ const mockConfig = {
getGeminiClient: () => null, // No client needed for these tests
getShellExecutionConfig: () => ({ terminalWidth: 80, terminalHeight: 24 }),
getChatRecordingService: () => undefined,
getMessageBus: vi.fn().mockReturnValue(undefined),
getEnableHooks: vi.fn().mockReturnValue(false),
getHookSystem: vi.fn().mockReturnValue(undefined),
getDebugLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
} as unknown as Config;
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', () => {
@ -103,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);
@ -209,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',
@ -258,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];
@ -343,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];
@ -376,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);
@ -523,7 +519,6 @@ describe('mapToDisplay', () => {
name: 'testTool',
displayName: 'Test Tool Display',
execute: vi.fn(),
shouldConfirmExecute: vi.fn(),
});
const baseResponse: ToolCallResponseInfo = {
@ -758,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

@ -6,10 +6,395 @@
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
import type {
ExportMessage,
ExportSessionData,
ExportMetadata,
} from './types.js';
/**
* File operation statistics extracted from tool calls.
*/
interface FileOperationStats {
filesWritten: number;
linesAdded: number;
linesRemoved: number;
writtenFilePaths: Set<string>;
}
/**
* Tool call arguments index for matching tool_result records.
*/
interface ToolCallArgsIndex {
byId: Map<string, Record<string, unknown>>;
byName: Map<string, Array<Record<string, unknown>>>;
}
/**
* Extracts tool name from a ChatRecord's function response.
*/
function extractToolNameFromRecord(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
return undefined;
}
/**
* Extracts call ID from a ChatRecord's function response.
*/
function extractFunctionResponseId(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.id) {
return part.functionResponse.id;
}
}
return undefined;
}
/**
* Normalizes function call args into a plain object.
*/
function normalizeFunctionCallArgs(
args: unknown,
): Record<string, unknown> | undefined {
if (args && typeof args === 'object') {
return args as Record<string, unknown>;
}
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore parse errors and treat as unavailable args
}
}
return undefined;
}
/**
* Builds an index of assistant tool calls for later tool_result arg resolution.
*/
function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex {
const byId = new Map<string, Record<string, unknown>>();
const byName = new Map<string, Array<Record<string, unknown>>>();
for (const record of records) {
if (record.type !== 'assistant' || !record.message?.parts) continue;
for (const part of record.message.parts) {
if (!('functionCall' in part) || !part.functionCall?.name) continue;
const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args);
if (!normalizedArgs) continue;
const toolName = part.functionCall.name;
const callId =
typeof part.functionCall.id === 'string' ? part.functionCall.id : null;
if (callId) {
byId.set(callId, normalizedArgs);
}
const queue = byName.get(toolName) ?? [];
queue.push(normalizedArgs);
byName.set(toolName, queue);
}
}
return { byId, byName };
}
/**
* Calculate file operation statistics from ChatRecords.
* Uses toolCallResult from tool_result records for accurate statistics.
*/
function calculateFileStats(records: ChatRecord[]): FileOperationStats {
const argsIndex = buildToolCallArgsIndex(records);
const byNameCursor = new Map<string, number>();
const stats: FileOperationStats = {
filesWritten: 0,
linesAdded: 0,
linesRemoved: 0,
writtenFilePaths: new Set(),
};
for (const record of records) {
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
const toolName = extractToolNameFromRecord(record);
const callId =
record.toolCallResult.callId ?? extractFunctionResponseId(record);
const argsFromId =
callId && argsIndex.byId.has(callId)
? argsIndex.byId.get(callId)
: undefined;
let args = argsFromId;
if (!args && toolName) {
const queue = argsIndex.byName.get(toolName);
if (queue && queue.length > 0) {
const cursor = byNameCursor.get(toolName) ?? 0;
args = queue[cursor];
byNameCursor.set(toolName, cursor + 1);
}
}
const { resultDisplay } = record.toolCallResult;
// Track file locations from resultDisplay
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
'fileName' in resultDisplay
) {
const display = resultDisplay as {
fileName: string;
fileDiff?: string;
originalContent?: string | null;
newContent?: string;
diffStat?: { model_added_lines?: number; model_removed_lines?: number };
};
// Determine operation type based on content fields
const hasOriginalContent = 'originalContent' in display;
const hasNewContent = 'newContent' in display;
// For write/edit operations, use full path from args if available
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;
} else {
// Fallback if fileName is not a string
filePath = 'unknown';
}
if (hasOriginalContent || hasNewContent) {
// This is a write/edit operation
stats.filesWritten++;
stats.writtenFilePaths.add(filePath);
// Calculate line changes
if (display.diffStat) {
// Use diffStat if available for accurate counts
stats.linesAdded += display.diffStat.model_added_lines ?? 0;
stats.linesRemoved += display.diffStat.model_removed_lines ?? 0;
} else {
// Fallback: count lines in content
const oldText = String(display.originalContent ?? '');
const newText = String(display.newContent ?? '');
// Count non-empty lines
const oldLines = oldText
.split('\n')
.filter((line) => line.length > 0).length;
const newLines = newText
.split('\n')
.filter((line) => line.length > 0).length;
stats.linesAdded += newLines;
stats.linesRemoved += oldLines;
}
}
}
}
return stats;
}
/**
* Extracts token usage from TaskResultDisplay executionSummary.
*/
function extractTaskToolTokens(record: ChatRecord): number {
if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) {
return 0;
}
const { resultDisplay } = record.toolCallResult;
if (
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
resultDisplay.type === 'task_execution' &&
'executionSummary' in resultDisplay
) {
const summary = resultDisplay.executionSummary as {
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
thoughtTokens?: number;
cachedTokens?: number;
};
// Use totalTokens if available, otherwise sum individual token counts
if (typeof summary.totalTokens === 'number') {
return summary.totalTokens;
}
// Fallback: sum available token counts
return (
(summary.inputTokens ?? 0) +
(summary.outputTokens ?? 0) +
(summary.thoughtTokens ?? 0) +
(summary.cachedTokens ?? 0)
);
}
return 0;
}
/**
* Calculate token statistics from ChatRecords.
* Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage.
* Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent.
*/
function calculateTokenStats(records: ChatRecord[]): {
totalTokens: number;
contextUsagePercent?: number;
contextWindowSize?: number;
} {
let totalTokens = 0;
// Track the last assistant record that has BOTH totalTokenCount and contextWindowSize
// to ensure the percentage calculation uses values from the same record
let lastValidRecord: {
totalTokenCount: number;
contextWindowSize: number;
} | null = null;
// Aggregate usageMetadata from all assistant records
for (const record of records) {
if (record.type === 'assistant') {
if (record.usageMetadata) {
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
}
// Only update lastValidRecord when BOTH values are present in the same record
if (
record.usageMetadata?.totalTokenCount !== undefined &&
record.contextWindowSize !== undefined
) {
lastValidRecord = {
totalTokenCount: record.usageMetadata.totalTokenCount,
contextWindowSize: record.contextWindowSize,
};
}
}
// Include TaskTool token usage from executionSummary
const taskTokens = extractTaskToolTokens(record);
if (taskTokens > 0) {
totalTokens += taskTokens;
}
}
// Use last valid record's values for context usage calculation
// This represents how much of the context window is being used by the total tokens
if (lastValidRecord) {
const percent =
(lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) *
100;
return {
totalTokens,
contextUsagePercent: Math.round(percent * 10) / 10,
contextWindowSize: lastValidRecord.contextWindowSize,
};
}
// Fallback: return the contextWindowSize from the last assistant record even if no valid pair found
// (for display purposes only, without percentage)
const lastAssistantRecord = [...records]
.reverse()
.find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined);
return {
totalTokens,
contextWindowSize: lastAssistantRecord?.contextWindowSize,
};
}
/**
* Extract session metadata from ChatRecords.
*/
async function extractMetadata(
conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
config: Config,
): Promise<ExportMetadata> {
const { sessionId, startTime, messages } = conversation;
// Extract basic info from the first record
const firstRecord = messages[0];
const cwd = firstRecord?.cwd ?? '';
const gitBranch = firstRecord?.gitBranch;
// Get git repository name
let gitRepo: string | undefined;
if (cwd) {
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
gitRepo = getGitRepoName(cwd);
}
// Try to get model from assistant messages
let model: string | undefined;
for (const record of messages) {
if (record.type === 'assistant' && record.model) {
model = record.model;
break;
}
}
// Get channel from config
const channel = config.getChannel?.();
// Count user prompts
const promptCount = messages.filter((m) => m.type === 'user').length;
// Calculate file stats from original ChatRecords
const fileStats = calculateFileStats(messages);
// Calculate token stats from original ChatRecords
// contextWindowSize is retrieved from the last assistant record for accuracy
const tokenStats = calculateTokenStats(messages);
return {
sessionId,
startTime,
exportTime: new Date().toISOString(),
cwd,
gitRepo,
gitBranch,
model,
channel,
promptCount,
contextUsagePercent: tokenStats.contextUsagePercent,
contextWindowSize: tokenStats.contextWindowSize,
totalTokens: tokenStats.totalTokens,
filesWritten: fileStats.writtenFilePaths.size,
linesAdded: fileStats.linesAdded,
linesRemoved: fileStats.linesRemoved,
uniqueFiles: Array.from(fileStats.writtenFilePaths),
};
}
/**
* Export session context that captures session updates into export messages.
@ -24,6 +409,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant' | 'thinking';
parts: Array<{ text: string }>;
timestamp: number;
usageMetadata?: GenerateContentResponseUsageMetadata;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
@ -39,9 +425,37 @@ class ExportSessionContext implements SessionContext {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
break;
case 'agent_message_chunk':
this.handleMessageChunk('assistant', update.content);
case 'agent_message_chunk': {
// Extract usageMetadata from _meta if available
const usageMeta = update._meta as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
thoughtTokens?: number;
cachedReadTokens?: number;
};
}
| undefined;
const usageMetadata: GenerateContentResponseUsageMetadata | undefined =
usageMeta?.usage
? {
promptTokenCount: usageMeta.usage.inputTokens,
candidatesTokenCount: usageMeta.usage.outputTokens,
totalTokenCount: usageMeta.usage.totalTokens,
thoughtsTokenCount: usageMeta.usage.thoughtTokens,
cachedContentTokenCount: usageMeta.usage.cachedReadTokens,
}
: undefined;
this.handleMessageChunk(
'assistant',
update.content,
'assistant',
usageMetadata,
);
break;
}
case 'agent_thought_chunk':
this.handleMessageChunk('assistant', update.content, 'thinking');
break;
@ -79,6 +493,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant',
content: { type: string; text?: string },
messageRole: 'user' | 'assistant' | 'thinking' = role,
usageMetadata?: GenerateContentResponseUsageMetadata,
): void {
if (content.type !== 'text' || !content.text) return;
@ -98,12 +513,17 @@ class ExportSessionContext implements SessionContext {
this.currentMessage.role === messageRole
) {
this.currentMessage.parts.push({ text: content.text });
// Merge usageMetadata if provided (for assistant messages)
if (usageMetadata && role === 'assistant') {
this.currentMessage.usageMetadata = usageMetadata;
}
} else {
this.currentMessage = {
type: role,
role: messageRole,
parts: [{ text: content.text }],
timestamp: Date.now(),
...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}),
};
}
}
@ -205,7 +625,7 @@ class ExportSessionContext implements SessionContext {
if (!this.currentMessage) return;
const uuid = this.getMessageUuid();
this.messages.push({
const exportMessage: ExportMessage = {
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
@ -214,7 +634,17 @@ class ExportSessionContext implements SessionContext {
role: this.currentMessage.role,
parts: this.currentMessage.parts,
},
});
};
// Add usageMetadata for assistant messages
if (
this.currentMessage.type === 'assistant' &&
this.currentMessage.usageMetadata
) {
exportMessage.usageMetadata = this.currentMessage.usageMetadata;
}
this.messages.push(exportMessage);
this.currentMessage = null;
}
@ -258,9 +688,13 @@ export async function collectSessionData(
// Get the export messages
const messages = exportContext.getMessages();
// Extract metadata from conversation
const metadata = await extractMetadata(conversation, config);
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages,
metadata,
};
}

View file

@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate(
sessionId: string;
startTime: string;
messages: unknown[];
metadata?: unknown;
},
): string {
const jsonData = JSON.stringify(data, null, 2);

View file

@ -12,15 +12,60 @@ import type { ExportSessionData } from '../types.js';
*/
export function toJsonl(sessionData: ExportSessionData): string {
const lines: string[] = [];
const sourceMetadata = sessionData.metadata;
// Add session metadata as the first line
lines.push(
JSON.stringify({
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
}),
);
const metadata: Record<string, unknown> = {
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
};
// Add all metadata fields if available
if (sourceMetadata?.exportTime) {
metadata['exportTime'] = sourceMetadata.exportTime;
}
if (sourceMetadata?.cwd) {
metadata['cwd'] = sourceMetadata.cwd;
}
if (sourceMetadata?.gitRepo) {
metadata['gitRepo'] = sourceMetadata.gitRepo;
}
if (sourceMetadata?.gitBranch) {
metadata['gitBranch'] = sourceMetadata.gitBranch;
}
if (sourceMetadata?.model) {
metadata['model'] = sourceMetadata.model;
}
if (sourceMetadata?.channel) {
metadata['channel'] = sourceMetadata.channel;
}
if (sourceMetadata?.promptCount !== undefined) {
metadata['promptCount'] = sourceMetadata.promptCount;
}
if (sourceMetadata?.contextUsagePercent !== undefined) {
metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent;
}
if (sourceMetadata?.contextWindowSize !== undefined) {
metadata['contextWindowSize'] = sourceMetadata.contextWindowSize;
}
if (sourceMetadata?.totalTokens !== undefined) {
metadata['totalTokens'] = sourceMetadata.totalTokens;
}
if (sourceMetadata?.filesWritten !== undefined) {
metadata['filesWritten'] = sourceMetadata.filesWritten;
}
if (sourceMetadata?.linesAdded !== undefined) {
metadata['linesAdded'] = sourceMetadata.linesAdded;
}
if (sourceMetadata?.linesRemoved !== undefined) {
metadata['linesRemoved'] = sourceMetadata.linesRemoved;
}
if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) {
metadata['uniqueFiles'] = sourceMetadata.uniqueFiles;
}
lines.push(JSON.stringify(metadata));
// Add each message as a separate line
for (const message of sessionData.messages) {

View file

@ -11,12 +11,82 @@ import type { ExportSessionData, ExportMessage } from '../types.js';
*/
export function toMarkdown(sessionData: ExportSessionData): string {
const lines: string[] = [];
const metadata = sessionData.metadata;
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
lines.push(`- **Exported**: ${new Date().toISOString()}`);
lines.push(
`- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`,
);
lines.push('');
// Add context info
if (metadata?.cwd) {
lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``);
}
if (metadata?.gitRepo) {
lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`);
}
if (metadata?.gitBranch) {
lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``);
}
lines.push('');
// Add model info
if (metadata?.model) {
lines.push(`- **Model**: ${sanitizeText(metadata.model)}`);
}
if (metadata?.channel) {
lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`);
}
if (metadata?.promptCount !== undefined) {
lines.push(`- **Prompt Count**: ${metadata.promptCount}`);
}
lines.push('');
// Add token stats
if (metadata?.totalTokens !== undefined) {
lines.push(`- **Total Tokens**: ${metadata.totalTokens}`);
}
if (metadata?.contextWindowSize !== undefined) {
lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`);
}
if (metadata?.contextUsagePercent !== undefined) {
lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`);
}
lines.push('');
// Add file operation stats
if (metadata?.filesWritten !== undefined) {
lines.push(`- **Files Written**: ${metadata.filesWritten}`);
}
if (metadata?.linesAdded !== undefined) {
lines.push(`- **Lines Added**: ${metadata.linesAdded}`);
}
if (metadata?.linesRemoved !== undefined) {
lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`);
}
// Add unique files list if available
if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) {
lines.push('');
lines.push('<details>');
lines.push(
`<summary><strong>Unique Files Referenced (${metadata.uniqueFiles.length})</strong></summary>`,
);
lines.push('');
for (const file of metadata.uniqueFiles) {
lines.push(`- \`${sanitizeText(file)}\``);
}
lines.push('</details>');
}
lines.push('\n---\n');
// Process each message

View file

@ -28,6 +28,14 @@ export function normalizeSessionData(
}
});
// Build index of assistant messages by uuid for usageMetadata merging
const assistantMessageIndexByUuid = new Map<string, number>();
normalized.forEach((message, index) => {
if (message.type === 'assistant') {
assistantMessageIndexByUuid.set(message.uuid, index);
}
});
// Merge tool result information into tool call messages
for (const record of originalRecords) {
if (record.type !== 'tool_result') continue;
@ -58,6 +66,20 @@ export function normalizeSessionData(
mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall);
}
// Merge usageMetadata from assistant records
for (const record of originalRecords) {
if (record.type !== 'assistant') continue;
if (!record.usageMetadata) continue;
const existingIndex = assistantMessageIndexByUuid.get(record.uuid);
if (existingIndex !== undefined) {
// Only set if not already present from collect phase
if (!normalized[existingIndex].usageMetadata) {
normalized[existingIndex].usageMetadata = record.usageMetadata;
}
}
}
return {
...sessionData,
messages: normalized,

View file

@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
/**
* Universal export message format - SSOT for all export formats.
* This is format-agnostic and contains all information needed for any export type.
@ -25,6 +27,9 @@ export interface ExportMessage {
/** Model used for assistant messages */
model?: string;
/** Token usage for this message (mainly for assistant messages) */
usageMetadata?: GenerateContentResponseUsageMetadata;
/** For tool_call messages */
toolCall?: {
toolCallId: string;
@ -44,6 +49,44 @@ export interface ExportMessage {
};
}
/**
* Metadata for export session - contains aggregated statistics and session context.
*/
export interface ExportMetadata {
/** Session ID */
sessionId: string;
/** ISO timestamp when session started */
startTime: string;
/** Export timestamp */
exportTime: string;
/** Current working directory */
cwd: string;
/** Git repository name, if available */
gitRepo?: string;
/** Git branch name, if available */
gitBranch?: string;
/** Model used in the session */
model?: string;
/** Channel/source identifier */
channel?: string;
/** Number of user prompts in the session */
promptCount: number;
/** Context window utilization percentage (0-100) */
contextUsagePercent?: number;
/** Context window size in tokens (used for calculating percentage) */
contextWindowSize?: number;
/** Total tokens used (prompt + completion) */
totalTokens?: number;
/** Number of files written/edited */
filesWritten?: number;
/** Lines of code added */
linesAdded?: number;
/** Lines of code removed */
linesRemoved?: number;
/** Unique files referenced in the session (written files only) */
uniqueFiles: string[];
}
/**
* Complete export session data - the single source of truth.
*/
@ -51,4 +94,6 @@ export interface ExportSessionData {
sessionId: string;
startTime: string;
messages: ExportMessage[];
/** Session metadata and statistics */
metadata?: ExportMetadata;
}