mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-03 06:00:49 +00:00
Merge branch 'main' into feat/hooks-plugin
This commit is contained in:
commit
857c7fb99b
184 changed files with 14809 additions and 2046 deletions
|
|
@ -64,6 +64,7 @@ import type { CliArgs } from '../config/config.js';
|
|||
import { loadCliConfig } from '../config/config.js';
|
||||
import { Session } from './session/Session.js';
|
||||
import { formatAcpModelId } from '../utils/acpModelUtils.js';
|
||||
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
|
||||
|
||||
const debugLogger = createDebugLogger('ACP_AGENT');
|
||||
|
||||
|
|
@ -87,7 +88,33 @@ export async function runAcpAgent(
|
|||
stream,
|
||||
);
|
||||
|
||||
// Handle SIGTERM/SIGINT for graceful shutdown.
|
||||
// Without this, signal handlers registered elsewhere in the CLI
|
||||
// (e.g., stdin raw mode restoration) override the default exit behavior,
|
||||
// causing the ACP process to ignore termination signals.
|
||||
let shuttingDown = false;
|
||||
const shutdownHandler = () => {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
debugLogger.debug('[ACP] Shutdown signal received, closing streams');
|
||||
try {
|
||||
process.stdin.destroy();
|
||||
} catch {
|
||||
// stdin may already be closed
|
||||
}
|
||||
try {
|
||||
process.stdout.destroy();
|
||||
} catch {
|
||||
// stdout may already be closed
|
||||
}
|
||||
};
|
||||
process.on('SIGTERM', shutdownHandler);
|
||||
process.on('SIGINT', shutdownHandler);
|
||||
|
||||
await connection.closed;
|
||||
|
||||
process.off('SIGTERM', shutdownHandler);
|
||||
process.off('SIGINT', shutdownHandler);
|
||||
}
|
||||
|
||||
function toStdioServer(server: McpServer): McpServerStdio | undefined {
|
||||
|
|
@ -188,8 +215,14 @@ class QwenAgent implements Agent {
|
|||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
const exists = await sessionService.sessionExists(params.sessionId);
|
||||
const exists = await runWithAcpRuntimeOutputDir(
|
||||
this.settings,
|
||||
params.cwd,
|
||||
async () => {
|
||||
const sessionService = new SessionService(params.cwd);
|
||||
return sessionService.sessionExists(params.sessionId);
|
||||
},
|
||||
);
|
||||
if (!exists) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
|
|
@ -230,10 +263,12 @@ class QwenAgent implements Agent {
|
|||
params: ListSessionsRequest,
|
||||
): Promise<ListSessionsResponse> {
|
||||
const cwd = params.cwd || process.cwd();
|
||||
const sessionService = new SessionService(cwd);
|
||||
const numericCursor = params.cursor ? Number(params.cursor) : undefined;
|
||||
const result = await sessionService.listSessions({
|
||||
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
||||
const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => {
|
||||
const sessionService = new SessionService(cwd);
|
||||
return sessionService.listSessions({
|
||||
cursor: Number.isNaN(numericCursor) ? undefined : numericCursor,
|
||||
});
|
||||
});
|
||||
|
||||
const sessions: SessionInfo[] = result.items.map((item) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import path from 'node:path';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { runWithAcpRuntimeOutputDir } from './runtimeOutputDirContext.js';
|
||||
|
||||
describe('runWithAcpRuntimeOutputDir', () => {
|
||||
beforeEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
it('uses the merged runtimeOutputDir relative to cwd within the async context', async () => {
|
||||
const cwd = path.resolve('workspace', 'project-a');
|
||||
const settings = {
|
||||
merged: {
|
||||
advanced: {
|
||||
runtimeOutputDir: '.qwen-runtime',
|
||||
},
|
||||
},
|
||||
} as LoadedSettings;
|
||||
|
||||
await runWithAcpRuntimeOutputDir(settings, cwd, async () => {
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen-runtime'));
|
||||
});
|
||||
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
});
|
||||
14
packages/cli/src/acp-integration/runtimeOutputDirContext.ts
Normal file
14
packages/cli/src/acp-integration/runtimeOutputDirContext.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
|
||||
export function runWithAcpRuntimeOutputDir<T>(
|
||||
settings: LoadedSettings,
|
||||
cwd: string,
|
||||
fn: () => T,
|
||||
): T {
|
||||
return Storage.runWithRuntimeBaseDir(
|
||||
settings.merged.advanced?.runtimeOutputDir,
|
||||
cwd,
|
||||
fn,
|
||||
);
|
||||
}
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ChatRecord, TaskResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type { ChatRecord, AgentResultDisplay } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
Content,
|
||||
GenerateContentResponseUsageMetadata,
|
||||
|
|
@ -165,16 +165,16 @@ export class HistoryReplayer {
|
|||
(resultDisplay as { type?: unknown }).type === 'task_execution'
|
||||
) {
|
||||
await this.emitTaskUsageFromResultDisplay(
|
||||
resultDisplay as TaskResultDisplay,
|
||||
resultDisplay as AgentResultDisplay,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits token usage from a TaskResultDisplay execution summary, if present.
|
||||
* Emits token usage from a AgentResultDisplay execution summary, if present.
|
||||
*/
|
||||
private async emitTaskUsageFromResultDisplay(
|
||||
resultDisplay: TaskResultDisplay,
|
||||
resultDisplay: AgentResultDisplay,
|
||||
): Promise<void> {
|
||||
const summary = resultDisplay.executionSummary;
|
||||
if (!summary) {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ describe('Session', () => {
|
|||
switchModel: switchModelSpy,
|
||||
getModel: vi.fn().mockImplementation(() => currentModel),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getWorkingDir: vi.fn().mockReturnValue(process.cwd()),
|
||||
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue(undefined),
|
||||
|
|
@ -241,5 +242,38 @@ describe('Session', () => {
|
|||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('runs prompt inside runtime output dir context', async () => {
|
||||
const runtimeDir = path.resolve('runtime', 'from-settings');
|
||||
core.Storage.setRuntimeBaseDir(runtimeDir);
|
||||
session = new Session(
|
||||
'test-session-id',
|
||||
mockChat,
|
||||
mockConfig,
|
||||
mockClient,
|
||||
mockSettings,
|
||||
);
|
||||
const runWithRuntimeBaseDirSpy = vi.spyOn(
|
||||
core.Storage,
|
||||
'runWithRuntimeBaseDir',
|
||||
);
|
||||
|
||||
mockChat.sendMessageStream = vi
|
||||
.fn()
|
||||
.mockResolvedValue((async function* () {})());
|
||||
|
||||
const promptRequest: PromptRequest = {
|
||||
sessionId: 'test-session-id',
|
||||
prompt: [{ type: 'text', text: 'hello' }],
|
||||
};
|
||||
|
||||
await session.prompt(promptRequest);
|
||||
|
||||
expect(runWithRuntimeBaseDirSpy).toHaveBeenCalledWith(
|
||||
runtimeDir,
|
||||
process.cwd(),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -29,11 +29,13 @@ import {
|
|||
logToolCall,
|
||||
logUserPrompt,
|
||||
getErrorStatus,
|
||||
TaskTool,
|
||||
AgentTool,
|
||||
UserPromptEvent,
|
||||
TodoWriteTool,
|
||||
ExitPlanModeTool,
|
||||
readManyFiles,
|
||||
Storage,
|
||||
ToolNames,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
|
|
@ -99,6 +101,7 @@ export class Session implements SessionContext {
|
|||
*/
|
||||
private pendingPromptCompletion: Promise<void> | null = null;
|
||||
private turn: number = 0;
|
||||
private readonly runtimeBaseDir: string;
|
||||
|
||||
// Modular components
|
||||
private readonly historyReplayer: HistoryReplayer;
|
||||
|
|
@ -117,6 +120,7 @@ export class Session implements SessionContext {
|
|||
private readonly settings: LoadedSettings,
|
||||
) {
|
||||
this.sessionId = id;
|
||||
this.runtimeBaseDir = Storage.getRuntimeBaseDir();
|
||||
|
||||
// Initialize modular components with this session as context
|
||||
this.toolCallEmitter = new ToolCallEmitter(this);
|
||||
|
|
@ -188,150 +192,170 @@ export class Session implements SessionContext {
|
|||
params: PromptRequest,
|
||||
pendingSend: AbortController,
|
||||
): Promise<PromptResponse> {
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
return Storage.runWithRuntimeBaseDir(
|
||||
this.runtimeBaseDir,
|
||||
this.config.getWorkingDir(),
|
||||
async () => {
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
const chat = this.chat;
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
const chat = this.chat;
|
||||
const promptId = this.config.getSessionId() + '########' + this.turn;
|
||||
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
const promptText = params.prompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
// Extract text from all text blocks to construct the full prompt text for logging
|
||||
const promptText = params.prompt
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join(' ');
|
||||
|
||||
// Log user prompt
|
||||
logUserPrompt(
|
||||
this.config,
|
||||
new UserPromptEvent(
|
||||
promptText.length,
|
||||
promptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
promptText,
|
||||
),
|
||||
);
|
||||
|
||||
// record user message for session management
|
||||
this.config.getChatRecordingService()?.recordUserMessage(promptText);
|
||||
|
||||
// Check if the input contains a slash command
|
||||
// Extract text from the first text block if present
|
||||
const firstTextBlock = params.prompt.find((block) => block.type === 'text');
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - uses default allowed commands (init, summary, compress)
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
parts = await this.#processSlashCommandResult(
|
||||
slashCommandResult,
|
||||
params.prompt,
|
||||
);
|
||||
|
||||
// If parts is null, the command was fully handled (e.g., /summary completed)
|
||||
// Return early without sending to the model
|
||||
if (parts === null) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.config.getModel(),
|
||||
{
|
||||
message: nextMessage?.parts ?? [],
|
||||
config: {
|
||||
abortSignal: pendingSend.signal,
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
// Log user prompt
|
||||
logUserPrompt(
|
||||
this.config,
|
||||
new UserPromptEvent(
|
||||
promptText.length,
|
||||
promptId,
|
||||
this.config.getContentGeneratorConfig()?.authType,
|
||||
promptText,
|
||||
),
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
// record user message for session management
|
||||
this.config.getChatRecordingService()?.recordUserMessage(promptText);
|
||||
|
||||
// Check if the input contains a slash command
|
||||
// Extract text from the first text block if present
|
||||
const firstTextBlock = params.prompt.find(
|
||||
(block) => block.type === 'text',
|
||||
);
|
||||
const inputText = firstTextBlock?.text || '';
|
||||
|
||||
let parts: Part[] | null;
|
||||
|
||||
if (isSlashCommand(inputText)) {
|
||||
// Handle slash command - uses default allowed commands (init, summary, compress)
|
||||
const slashCommandResult = await handleSlashCommand(
|
||||
inputText,
|
||||
pendingSend,
|
||||
this.config,
|
||||
this.settings,
|
||||
);
|
||||
|
||||
parts = await this.#processSlashCommandResult(
|
||||
slashCommandResult,
|
||||
params.prompt,
|
||||
);
|
||||
|
||||
// If parts is null, the command was fully handled (e.g., /summary completed)
|
||||
// Return early without sending to the model
|
||||
if (parts === null) {
|
||||
return { stopReason: 'end_turn' };
|
||||
}
|
||||
} else {
|
||||
// Normal processing for non-slash commands
|
||||
parts = await this.#resolvePrompt(params.prompt, pendingSend.signal);
|
||||
}
|
||||
|
||||
let nextMessage: Content | null = { role: 'user', parts };
|
||||
|
||||
while (nextMessage !== null) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
chat.addHistory(nextMessage);
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.candidates &&
|
||||
resp.value.candidates.length > 0
|
||||
) {
|
||||
const candidate = resp.value.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
const functionCalls: FunctionCall[] = [];
|
||||
let usageMetadata: GenerateContentResponseUsageMetadata | null = null;
|
||||
const streamStartTime = Date.now();
|
||||
|
||||
try {
|
||||
const responseStream = await chat.sendMessageStream(
|
||||
this.config.getModel(),
|
||||
{
|
||||
message: nextMessage?.parts ?? [],
|
||||
config: {
|
||||
abortSignal: pendingSend.signal,
|
||||
},
|
||||
},
|
||||
promptId,
|
||||
);
|
||||
nextMessage = null;
|
||||
|
||||
for await (const resp of responseStream) {
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.candidates &&
|
||||
resp.value.candidates.length > 0
|
||||
) {
|
||||
const candidate = resp.value.candidates[0];
|
||||
for (const part of candidate.content?.parts ?? []) {
|
||||
if (!part.text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
this.messageEmitter.emitMessage(
|
||||
part.text,
|
||||
'assistant',
|
||||
part.thought,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.usageMetadata
|
||||
) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
}
|
||||
|
||||
if (
|
||||
resp.type === StreamEventType.CHUNK &&
|
||||
resp.value.functionCalls
|
||||
) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new RequestError(
|
||||
429,
|
||||
'Rate limit exceeded. Try again later.',
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) {
|
||||
usageMetadata = resp.value.usageMetadata;
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (resp.type === StreamEventType.CHUNK && resp.value.functionCalls) {
|
||||
functionCalls.push(...resp.value.functionCalls);
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.runTool(
|
||||
pendingSend.signal,
|
||||
promptId,
|
||||
fc,
|
||||
);
|
||||
toolResponseParts.push(...response);
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (getErrorStatus(error) === 429) {
|
||||
throw new RequestError(429, 'Rate limit exceeded. Try again later.');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (usageMetadata) {
|
||||
const durationMs = Date.now() - streamStartTime;
|
||||
await this.messageEmitter.emitUsageMetadata(
|
||||
usageMetadata,
|
||||
'',
|
||||
durationMs,
|
||||
);
|
||||
}
|
||||
|
||||
if (functionCalls.length > 0) {
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const fc of functionCalls) {
|
||||
const response = await this.runTool(pendingSend.signal, promptId, fc);
|
||||
toolResponseParts.push(...response);
|
||||
}
|
||||
|
||||
nextMessage = { role: 'user', parts: toolResponseParts };
|
||||
}
|
||||
}
|
||||
|
||||
return { stopReason: 'end_turn' };
|
||||
return { stopReason: 'end_turn' };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async sendUpdate(update: SessionUpdate): Promise<void> {
|
||||
|
|
@ -517,7 +541,7 @@ export class Session implements SessionContext {
|
|||
|
||||
// Detect TodoWriteTool early - route to plan updates instead of tool_call events
|
||||
const isTodoWriteTool = tool.name === TodoWriteTool.Name;
|
||||
const isTaskTool = tool.name === TaskTool.Name;
|
||||
const isAgentTool = tool.name === AgentTool.Name;
|
||||
const isExitPlanModeTool = tool.name === ExitPlanModeTool.Name;
|
||||
|
||||
// Track cleanup functions for sub-agent event listeners
|
||||
|
|
@ -526,15 +550,15 @@ export class Session implements SessionContext {
|
|||
try {
|
||||
const invocation = tool.build(args);
|
||||
|
||||
if (isTaskTool && 'eventEmitter' in invocation) {
|
||||
// Access eventEmitter from TaskTool invocation
|
||||
if (isAgentTool && 'eventEmitter' in invocation) {
|
||||
// Access eventEmitter from AgentTool invocation
|
||||
const taskEventEmitter = (
|
||||
invocation as {
|
||||
eventEmitter: AgentEventEmitter;
|
||||
}
|
||||
).eventEmitter;
|
||||
|
||||
// Extract subagent metadata from TaskTool call
|
||||
// Extract subagent metadata from AgentTool call
|
||||
const parentToolCallId = callId;
|
||||
const subagentType = (args['subagent_type'] as string) ?? '';
|
||||
|
||||
|
|
@ -553,18 +577,17 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
// Use the new permission flow: getDefaultPermission + getConfirmationDetails
|
||||
// ask_user_question must always go through confirmation even in YOLO mode
|
||||
// so the user always has a chance to respond to questions.
|
||||
const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION;
|
||||
const defaultPermission =
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO ||
|
||||
isAskUserQuestionTool
|
||||
? await invocation.getDefaultPermission()
|
||||
: 'allow';
|
||||
|
||||
// In YOLO mode, auto-approve everything except ask_user_question
|
||||
// (the user must always have a chance to respond to questions)
|
||||
const isAskUserQuestionTool =
|
||||
confirmationDetails && confirmationDetails.type === 'ask_user_question';
|
||||
const effectiveConfirmationDetails =
|
||||
this.config.getApprovalMode() === ApprovalMode.YOLO &&
|
||||
!isAskUserQuestionTool
|
||||
? false
|
||||
: confirmationDetails;
|
||||
const needsConfirmation = defaultPermission === 'ask';
|
||||
|
||||
// Check for plan mode enforcement - block non-read-only tools
|
||||
// but allow ask_user_question so users can answer clarification questions
|
||||
|
|
@ -573,7 +596,7 @@ export class Session implements SessionContext {
|
|||
isPlanMode &&
|
||||
!isExitPlanModeTool &&
|
||||
!isAskUserQuestionTool &&
|
||||
effectiveConfirmationDetails
|
||||
needsConfirmation
|
||||
) {
|
||||
// In plan mode, block any tool that requires confirmation (write operations)
|
||||
return errorResponse(
|
||||
|
|
@ -584,25 +607,35 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
if (effectiveConfirmationDetails) {
|
||||
if (defaultPermission === 'deny') {
|
||||
return errorResponse(
|
||||
new Error(
|
||||
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (needsConfirmation) {
|
||||
const confirmationDetails =
|
||||
await invocation.getConfirmationDetails(abortSignal);
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
if (effectiveConfirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: effectiveConfirmationDetails.fileName,
|
||||
oldText: effectiveConfirmationDetails.originalContent,
|
||||
newText: effectiveConfirmationDetails.newContent,
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Add plan content for exit_plan_mode
|
||||
if (effectiveConfirmationDetails.type === 'plan') {
|
||||
if (confirmationDetails.type === 'plan') {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: effectiveConfirmationDetails.plan,
|
||||
text: confirmationDetails.plan,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -612,7 +645,7 @@ export class Session implements SessionContext {
|
|||
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.sessionId,
|
||||
options: toPermissionOptions(effectiveConfirmationDetails),
|
||||
options: toPermissionOptions(confirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
|
|
@ -636,7 +669,7 @@ export class Session implements SessionContext {
|
|||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
await effectiveConfirmationDetails.onConfirm(outcome, {
|
||||
await confirmationDetails.onConfirm(outcome, {
|
||||
answers: output.answers,
|
||||
});
|
||||
|
||||
|
|
@ -652,6 +685,8 @@ export class Session implements SessionContext {
|
|||
);
|
||||
case ToolConfirmationOutcome.ProceedOnce:
|
||||
case ToolConfirmationOutcome.ProceedAlways:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysProject:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysUser:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysServer:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysTool:
|
||||
case ToolConfirmationOutcome.ModifyWithEditor:
|
||||
|
|
@ -855,13 +890,12 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
case 'no_command':
|
||||
// No command was found or executed, use original prompt
|
||||
return originalPrompt.map((block) => {
|
||||
if (block.type === 'text') {
|
||||
return { text: block.text };
|
||||
}
|
||||
throw new Error(`Unsupported block type: ${block.type}`);
|
||||
});
|
||||
// No command was found or executed, resolve the original prompt
|
||||
// through the standard path that handles all block types
|
||||
return this.#resolvePrompt(
|
||||
originalPrompt,
|
||||
new AbortController().signal,
|
||||
);
|
||||
|
||||
default: {
|
||||
// Exhaustiveness check
|
||||
|
|
@ -1041,8 +1075,13 @@ function toPermissionOptions(
|
|||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
@ -1050,13 +1089,13 @@ function toPermissionOptions(
|
|||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
@ -1064,8 +1103,13 @@ function toPermissionOptions(
|
|||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ const basicPermissionOptions: readonly PermissionOptionConfig[] = [
|
|||
] as const;
|
||||
|
||||
/**
|
||||
* Tracks and emits events for sub-agent tool calls within TaskTool execution.
|
||||
* Tracks and emits events for sub-agent tool calls within AgentTool execution.
|
||||
*
|
||||
* Uses the unified ToolCallEmitter for consistency with normal flow
|
||||
* and history replay. Also handles permission requests for tools that
|
||||
|
|
@ -106,7 +106,7 @@ export class SubAgentTracker {
|
|||
/**
|
||||
* Sets up event listeners for a sub-agent's tool events.
|
||||
*
|
||||
* @param eventEmitter - The AgentEventEmitter from TaskTool
|
||||
* @param eventEmitter - The AgentEventEmitter from AgentTool
|
||||
* @param abortSignal - Signal to abort tracking if parent is cancelled
|
||||
* @returns Array of cleanup functions to remove listeners
|
||||
*/
|
||||
|
|
@ -330,6 +330,8 @@ export class SubAgentTracker {
|
|||
private toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): PermissionOption[] {
|
||||
const hideAlwaysAllow =
|
||||
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
@ -342,34 +344,56 @@ export class SubAgentTracker {
|
|||
];
|
||||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Always Allow',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: 'Always Allow in project',
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: 'Always Allow for user',
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'plan':
|
||||
|
|
|
|||
|
|
@ -225,7 +225,13 @@ export class ToolCallEmitter extends BaseEmitter {
|
|||
// Pass tool name to handle special cases like exit_plan_mode -> switch_mode
|
||||
kind = this.mapToolKind(tool.kind, toolName);
|
||||
} catch {
|
||||
// Use defaults on build failure
|
||||
// Fallback: use the description arg directly if available
|
||||
if (typeof args['description'] === 'string') {
|
||||
title = `${title}: ${args['description']}`;
|
||||
}
|
||||
if (tool.kind) {
|
||||
kind = this.mapToolKind(tool.kind, toolName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,9 +35,9 @@ export interface SessionContext extends SessionUpdateSender {
|
|||
* Subagent metadata for tracking parent tool call context.
|
||||
*/
|
||||
export interface SubagentMeta {
|
||||
/** ID of the parent TaskTool call that created this subagent */
|
||||
/** ID of the parent AgentTool call that created this subagent */
|
||||
parentToolCallId?: string;
|
||||
/** Type of subagent (from TaskParams.subagent_type) */
|
||||
/** Type of subagent (from AgentParams.subagent_type) */
|
||||
subagentType?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
DEFAULT_QWEN_MODEL,
|
||||
OutputFormat,
|
||||
NativeLspService,
|
||||
Storage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
|
||||
import type { Settings } from './settings.js';
|
||||
|
|
@ -1022,7 +1023,7 @@ describe('mergeExcludeTools', () => {
|
|||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual([]);
|
||||
expect(config.getPermissionsDeny()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
|
||||
|
|
@ -1031,7 +1032,7 @@ describe('mergeExcludeTools', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||
expect(config.getPermissionsDeny()).toEqual(defaultExcludes);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
|
|
@ -1039,10 +1040,10 @@ describe('mergeExcludeTools', () => {
|
|||
const argv = await parseArguments();
|
||||
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
expect(config.getPermissionsDeny()).toEqual(
|
||||
expect.arrayContaining(['tool1', 'tool2']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(2);
|
||||
expect(config.getPermissionsDeny()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1067,7 +1068,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1086,7 +1087,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1106,7 +1107,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1123,7 +1124,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1140,7 +1141,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1160,7 +1161,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1180,7 +1181,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1193,7 +1194,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1218,7 +1219,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1238,7 +1239,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = { tools: { exclude: ['custom_tool'] } };
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain('custom_tool'); // From settings
|
||||
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
|
||||
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
|
||||
|
|
@ -1834,9 +1835,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
|
||||
|
|
@ -1844,9 +1845,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
|
||||
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
|
||||
|
|
@ -1854,9 +1855,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).toContain('edit');
|
||||
expect(config.getExcludeTools()).toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).toContain('edit');
|
||||
expect(config.getPermissionsDeny()).toContain('write_file');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||
|
|
@ -1864,9 +1865,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -2439,3 +2440,79 @@ describe('Telemetry configuration via environment variables', () => {
|
|||
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadCliConfig runtimeOutputDir', () => {
|
||||
const originalArgv = process.argv;
|
||||
const originalRuntimeEnv = process.env['QWEN_RUNTIME_DIR'];
|
||||
|
||||
beforeEach(() => {
|
||||
process.argv = ['node', 'script.js'];
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.argv = originalArgv;
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
if (originalRuntimeEnv !== undefined) {
|
||||
process.env['QWEN_RUNTIME_DIR'] = originalRuntimeEnv;
|
||||
} else {
|
||||
delete process.env['QWEN_RUNTIME_DIR'];
|
||||
}
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should set runtime base dir from settings with absolute path', async () => {
|
||||
const runtimeDir = path.resolve('custom', 'runtime');
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
advanced: { runtimeOutputDir: runtimeDir },
|
||||
};
|
||||
await loadCliConfig(settings, argv);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(runtimeDir);
|
||||
});
|
||||
|
||||
it('should resolve relative runtimeOutputDir against cwd', async () => {
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
advanced: { runtimeOutputDir: '.qwen' },
|
||||
};
|
||||
const cwd = path.resolve('workspace', 'my-project');
|
||||
await loadCliConfig(settings, argv, cwd);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(path.join(cwd, '.qwen'));
|
||||
});
|
||||
|
||||
it('should not set runtime base dir when runtimeOutputDir is absent', async () => {
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {};
|
||||
await loadCliConfig(settings, argv);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
|
||||
it('should let QWEN_RUNTIME_DIR env var take priority over settings', async () => {
|
||||
const envDir = path.resolve('from-env');
|
||||
const settingsDir = path.resolve('from-settings');
|
||||
process.env['QWEN_RUNTIME_DIR'] = envDir;
|
||||
const argv = await parseArguments();
|
||||
const settings: Settings = {
|
||||
advanced: { runtimeOutputDir: settingsDir },
|
||||
};
|
||||
await loadCliConfig(settings, argv);
|
||||
// getRuntimeBaseDir checks env var first at call time
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(envDir);
|
||||
});
|
||||
|
||||
it('should reset runtime base dir on subsequent load when runtimeOutputDir is absent', async () => {
|
||||
const argv = await parseArguments();
|
||||
const firstRuntimeDir = path.resolve('first', 'runtime');
|
||||
await loadCliConfig(
|
||||
{ advanced: { runtimeOutputDir: firstRuntimeDir } },
|
||||
argv,
|
||||
);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(firstRuntimeDir);
|
||||
|
||||
await loadCliConfig({}, argv);
|
||||
expect(Storage.getRuntimeBaseDir()).toBe(Storage.getGlobalQwenDir());
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import {
|
|||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
ideContextStore,
|
||||
type ResumedSessionData,
|
||||
|
|
@ -30,11 +29,13 @@ import {
|
|||
NativeLspClient,
|
||||
createDebugLogger,
|
||||
NativeLspService,
|
||||
isToolEnabled,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import type { Settings, LoadedSettings } from './settings.js';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { authCommand } from '../commands/auth.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
getAuthTypeFromEnv,
|
||||
|
|
@ -398,6 +399,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('include-directories', {
|
||||
alias: 'add-dir',
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
|
|
@ -702,9 +704,15 @@ export async function loadCliConfig(
|
|||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
overrideExtensions?: string[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
// Set runtime output directory from settings (env var QWEN_RUNTIME_DIR
|
||||
// is auto-detected inside getRuntimeBaseDir() at each call site).
|
||||
// Pass cwd so that relative paths like ".qwen" resolve per-project.
|
||||
Storage.setRuntimeBaseDir(settings.advanced?.runtimeOutputDir, cwd);
|
||||
|
||||
const ideMode = settings.ide?.enabled ?? false;
|
||||
|
||||
const folderTrust = settings.security?.folderTrust?.enabled ?? false;
|
||||
|
|
@ -831,64 +839,106 @@ export async function loadCliConfig(
|
|||
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||
const resolvedAllowedTools =
|
||||
argv.allowedTools || settings.tools?.allowed || [];
|
||||
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||
return true;
|
||||
}
|
||||
// ── Unified permissions construction ─────────────────────────────────────
|
||||
// All permission sources are merged here, before constructing Config.
|
||||
// The resulting three arrays are the single source of truth that Config /
|
||||
// PermissionManager will use.
|
||||
//
|
||||
// Sources (in order of precedence within each list):
|
||||
// 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings)
|
||||
// 2. argv.coreTools → allow (allowlist mode: only these tools are available)
|
||||
// 3. argv.allowedTools → allow (auto-approve these tools/commands)
|
||||
// 4. argv.excludeTools → deny (block these tools completely)
|
||||
// 5. Non-interactive mode exclusions → deny (unless explicitly allowed above)
|
||||
|
||||
// Start from settings-level rules.
|
||||
// Read from both new `permissions` and legacy `tools` paths for compatibility.
|
||||
// Note: settings.tools.core / argv.coreTools are intentionally NOT merged into
|
||||
// mergedAllow — they have whitelist semantics (only listed tools are registered),
|
||||
// not auto-approve semantics. They are passed via the `coreTools` Config param
|
||||
// and handled by PermissionManager.coreToolsAllowList.
|
||||
const resolvedCoreTools: string[] = [
|
||||
...(argv.coreTools ?? []),
|
||||
...(settings.tools?.core ?? []),
|
||||
];
|
||||
const mergedAllow: string[] = [
|
||||
...(settings.permissions?.allow ?? []),
|
||||
...(settings.tools?.allowed ?? []),
|
||||
];
|
||||
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
|
||||
const mergedDeny: string[] = [
|
||||
...(settings.permissions?.deny ?? []),
|
||||
...(settings.tools?.exclude ?? []),
|
||||
];
|
||||
|
||||
// argv.allowedTools adds allow rules (auto-approve).
|
||||
for (const t of argv.allowedTools ?? []) {
|
||||
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
|
||||
}
|
||||
|
||||
// argv.excludeTools adds deny rules.
|
||||
for (const t of argv.excludeTools ?? []) {
|
||||
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
|
||||
}
|
||||
|
||||
// Helper: check if a tool is explicitly covered by an allow rule OR by the
|
||||
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
|
||||
// to preserve the original behaviour where "ShellTool", "Shell", and
|
||||
// "run_shell_command" are all accepted as the same tool.
|
||||
const isExplicitlyAllowed = (toolName: ToolName): boolean => {
|
||||
const name = toolName as string;
|
||||
// 1. Check permissions.allow / allowedTools rules.
|
||||
if (
|
||||
mergedAllow.some((rule) => {
|
||||
const openParen = rule.indexOf('(');
|
||||
const ruleName =
|
||||
openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim();
|
||||
return ruleName === name;
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (resolvedAllowedTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||
return true;
|
||||
}
|
||||
// 2. Check coreTools whitelist (with alias matching).
|
||||
// If coreTools is non-empty and explicitly includes this tool, it is
|
||||
// considered allowed for non-interactive mode exclusion purposes.
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
return isToolEnabled(toolName, resolvedCoreTools, []);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyEnabled(toolName)) {
|
||||
extraExcludes.push(toolName);
|
||||
}
|
||||
};
|
||||
|
||||
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
|
||||
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
|
||||
// In non-interactive mode, tools that require a user prompt are denied unless
|
||||
// the caller has explicitly allowed them. Stream-JSON input is excluded from
|
||||
// this logic because approval can be sent programmatically via JSON messages.
|
||||
const isAcpMode = argv.acp || argv.experimentalAcp;
|
||||
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
|
||||
const denyUnlessAllowed = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyAllowed(toolName)) {
|
||||
const name = toolName as string;
|
||||
if (!mergedDeny.includes(name)) mergedDeny.push(name);
|
||||
}
|
||||
};
|
||||
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
// Deny all write/execute tools unless explicitly allowed.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
denyUnlessAllowed(EditTool.Name as ToolName);
|
||||
denyUnlessAllowed(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
// Only shell requires a prompt in auto-edit mode.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
// No extra denials for YOLO mode.
|
||||
break;
|
||||
default:
|
||||
// This should never happen due to validation earlier, but satisfies the linter
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
|
||||
let allowedMcpServers: Set<string> | undefined;
|
||||
let excludedMcpServers: Set<string> | undefined;
|
||||
if (argv.allowedMcpServerNames) {
|
||||
|
|
@ -981,9 +1031,31 @@ export async function loadCliConfig(
|
|||
question,
|
||||
systemPrompt: argv.systemPrompt,
|
||||
appendSystemPrompt: argv.appendSystemPrompt,
|
||||
// Legacy fields – kept for backward compatibility with getCoreTools() etc.
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
excludeTools: mergedDeny,
|
||||
// New unified permissions (PermissionManager source of truth).
|
||||
permissions: {
|
||||
allow: mergedAllow.length > 0 ? mergedAllow : undefined,
|
||||
ask: mergedAsk.length > 0 ? mergedAsk : undefined,
|
||||
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
|
||||
},
|
||||
// Permission rule persistence callback (writes to settings files).
|
||||
onPersistPermissionRule: loadedSettings
|
||||
? async (scope, ruleType, rule) => {
|
||||
const settingScope =
|
||||
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
|
||||
const key = `permissions.${ruleType}`;
|
||||
const currentRules: string[] =
|
||||
loadedSettings.forScope(settingScope).settings.permissions?.[
|
||||
ruleType
|
||||
] ?? [];
|
||||
if (!currentRules.includes(rule)) {
|
||||
loadedSettings.setValue(settingScope, key, [...currentRules, rule]);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
|
|
@ -1102,16 +1174,3 @@ export async function loadCliConfig(
|
|||
|
||||
return config;
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
return [...allExcludeTools];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -864,6 +864,55 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: 'object',
|
||||
label: 'Permissions',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allow: {
|
||||
type: 'array',
|
||||
label: 'Allow Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are auto-approved without confirmation. ' +
|
||||
'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ask: {
|
||||
type: 'array',
|
||||
label: 'Ask Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that always require user confirmation. ' +
|
||||
'Takes precedence over allow rules.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
deny: {
|
||||
type: 'array',
|
||||
label: 'Deny Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are always blocked. Highest priority rule. ' +
|
||||
'Examples: "ShellTool", "Bash(rm -rf *)".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
type: 'object',
|
||||
label: 'Tools',
|
||||
|
|
@ -923,32 +972,33 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Legacy tool permission fields – kept for backward compatibility.
|
||||
// Use permissions.{allow,ask,deny} instead.
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
label: 'Core Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Paths to core tool definitions.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allowed Tools',
|
||||
label: 'Allowed Tools (deprecated)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'A list of tool names that will bypass the confirmation dialog.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
label: 'Exclude Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Tool names to exclude from discovery.',
|
||||
description: 'Deprecated. Use permissions.deny instead.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
|
|
@ -1213,6 +1263,17 @@ const SETTINGS_SCHEMA = {
|
|||
description: 'Configuration for the bug report command.',
|
||||
showInDialog: false,
|
||||
},
|
||||
runtimeOutputDir: {
|
||||
type: 'string',
|
||||
label: 'Runtime Output Directory',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string | undefined,
|
||||
description:
|
||||
'Custom directory for runtime output (temp files, debug logs, session data, todos, etc.). ' +
|
||||
'Config files remain at ~/.qwen. Env var QWEN_RUNTIME_DIR takes priority.',
|
||||
showInDialog: false,
|
||||
},
|
||||
tavilyApiKey: {
|
||||
type: 'string',
|
||||
label: 'Tavily API Key (Deprecated)',
|
||||
|
|
|
|||
|
|
@ -326,8 +326,15 @@ export async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle --resume without a session ID by showing the session picker
|
||||
// Handle --resume without a session ID by showing the session picker.
|
||||
// Set the runtime output dir early so the picker can find sessions stored
|
||||
// under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will
|
||||
// be called again inside loadCliConfig).
|
||||
if (argv.resume === '') {
|
||||
Storage.setRuntimeBaseDir(
|
||||
settings.merged.advanced?.runtimeOutputDir,
|
||||
process.cwd(),
|
||||
);
|
||||
const selectedSessionId = await showResumeSessionPicker();
|
||||
if (!selectedSessionId) {
|
||||
// User cancelled or no sessions available
|
||||
|
|
@ -351,6 +358,7 @@ export async function main() {
|
|||
argv,
|
||||
process.cwd(),
|
||||
argv.extensions,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Register cleanup for MCP clients as early as possible
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}} 個のファイルを開いています',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}}',
|
||||
|
||||
// ============================================================================
|
||||
// Строка состояния
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
type Config,
|
||||
type ServerGeminiStreamEvent,
|
||||
type ToolCallRequestInfo,
|
||||
type TaskResultDisplay,
|
||||
type AgentResultDisplay,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai';
|
||||
import type {
|
||||
|
|
@ -144,7 +144,7 @@ class TestJsonOutputAdapter extends BaseJsonOutputAdapter {
|
|||
|
||||
exposeCreateSubagentToolUseBlock(
|
||||
state: MessageState,
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
parentToolUseId: string,
|
||||
) {
|
||||
return this.createSubagentToolUseBlock(state, toolCall, parentToolUseId);
|
||||
|
|
@ -1314,7 +1314,7 @@ describe('BaseJsonOutputAdapter', () => {
|
|||
it('should process subagent tool call', () => {
|
||||
const parentToolUseId = 'parent-tool-1';
|
||||
adapter.startSubagentAssistantMessage(parentToolUseId);
|
||||
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
|
||||
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: { param: 'value' },
|
||||
|
|
@ -1346,7 +1346,7 @@ describe('BaseJsonOutputAdapter', () => {
|
|||
const state = adapter.exposeGetMessageState(parentToolUseId);
|
||||
adapter.exposeAppendText(state, 'Text', parentToolUseId);
|
||||
|
||||
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
|
||||
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
|
|
@ -1367,7 +1367,7 @@ describe('BaseJsonOutputAdapter', () => {
|
|||
it('should create tool_use block for subagent', () => {
|
||||
const state = adapter.exposeCreateMessageState();
|
||||
adapter.startAssistantMessage();
|
||||
const toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number] = {
|
||||
const toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number] = {
|
||||
callId: 'tool-1',
|
||||
name: 'test_tool',
|
||||
args: { param: 'value' },
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type {
|
|||
ToolCallResponseInfo,
|
||||
SessionMetrics,
|
||||
ServerGeminiStreamEvent,
|
||||
TaskResultDisplay,
|
||||
AgentResultDisplay,
|
||||
McpToolProgressData,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
|
|
@ -110,7 +110,7 @@ export interface JsonOutputAdapterInterface extends MessageEmitter {
|
|||
|
||||
startSubagentAssistantMessage?(parentToolUseId: string): void;
|
||||
processSubagentToolCall?(
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
parentToolUseId: string,
|
||||
): void;
|
||||
finalizeSubagentAssistantMessage?(
|
||||
|
|
@ -693,7 +693,7 @@ export abstract class BaseJsonOutputAdapter {
|
|||
* @param parentToolUseId - Parent tool use ID
|
||||
*/
|
||||
processSubagentToolCall(
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
parentToolUseId: string,
|
||||
): void {
|
||||
const state = this.getMessageState(parentToolUseId);
|
||||
|
|
@ -744,7 +744,7 @@ export abstract class BaseJsonOutputAdapter {
|
|||
protected processSubagentToolUseBlock(
|
||||
state: MessageState,
|
||||
index: number,
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
parentToolUseId: string,
|
||||
): void {
|
||||
// Emit tool_use block creation event (with empty input)
|
||||
|
|
@ -937,7 +937,7 @@ export abstract class BaseJsonOutputAdapter {
|
|||
*/
|
||||
protected createSubagentToolUseBlock(
|
||||
state: MessageState,
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
_parentToolUseId: string,
|
||||
): { block: ToolUseBlock; index: number } {
|
||||
const index = state.blocks.length;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ import {
|
|||
extractPartsFromUserMessage,
|
||||
buildSystemMessage,
|
||||
createToolProgressHandler,
|
||||
createTaskToolProgressHandler,
|
||||
createAgentToolProgressHandler,
|
||||
computeUsageFromMetrics,
|
||||
} from './utils/nonInteractiveHelpers.js';
|
||||
|
||||
|
|
@ -320,12 +320,12 @@ export async function runNonInteractive(
|
|||
: undefined;
|
||||
|
||||
// Build outputUpdateHandler for this tool call.
|
||||
// Task tool has its own complex handler (subagent messages).
|
||||
// Agent tool has its own complex handler (subagent messages).
|
||||
// All other tools with canUpdateOutput=true (e.g., MCP tools)
|
||||
// get a generic handler that emits progress via the adapter.
|
||||
const isTaskTool = finalRequestInfo.name === 'task';
|
||||
const { handler: outputUpdateHandler } = isTaskTool
|
||||
? createTaskToolProgressHandler(
|
||||
const isAgentTool = finalRequestInfo.name === 'agent';
|
||||
const { handler: outputUpdateHandler } = isAgentTool
|
||||
? createAgentToolProgressHandler(
|
||||
config,
|
||||
finalRequestInfo.callId,
|
||||
adapter,
|
||||
|
|
|
|||
|
|
@ -37,12 +37,22 @@ vi.mock('../ui/commands/ideCommand.js', async () => {
|
|||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../ui/commands/trustCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
trustCommand: {
|
||||
name: 'trust',
|
||||
description: 'Trust command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../ui/commands/permissionsCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
permissionsCommand: {
|
||||
name: 'permissions',
|
||||
description: 'Permissions command',
|
||||
description: 'Manage permission rules',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
|
|
@ -174,19 +184,19 @@ describe('BuiltinCommandLoader', () => {
|
|||
expect(modelCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include permissions command when folder trust is enabled', async () => {
|
||||
it('should include trust command when folder trust is enabled', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeDefined();
|
||||
const trustCmd = commands.find((c) => c.name === 'trust');
|
||||
expect(trustCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude permissions command when folder trust is disabled', async () => {
|
||||
it('should exclude trust command when folder trust is disabled', async () => {
|
||||
(mockConfig.getFolderTrust as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeUndefined();
|
||||
const trustCmd = commands.find((c) => c.name === 'trust');
|
||||
expect(trustCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always include modelCommand', async () => {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
|||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { trustCommand } from '../ui/commands/trustCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
|
|
@ -84,7 +85,8 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
mcpCommand,
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
permissionsCommand,
|
||||
...(this.config?.getFolderTrust() ? [trustCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import path from 'path';
|
||||
import fs from 'fs/promises';
|
||||
import { Storage, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { StaticInsightGenerator } from './StaticInsightGenerator.js';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
default: {
|
||||
mkdir: vi.fn(),
|
||||
access: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
unlink: vi.fn(),
|
||||
symlink: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('StaticInsightGenerator', () => {
|
||||
const mockedFs = vi.mocked(fs);
|
||||
const mockConfig = {} as Config;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-03-05T12:34:56.000Z'));
|
||||
Storage.setRuntimeBaseDir(path.resolve('runtime-output'));
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockedFs.mkdir.mockResolvedValue(undefined);
|
||||
mockedFs.access.mockRejectedValue(new Error('not found'));
|
||||
mockedFs.writeFile.mockResolvedValue(undefined);
|
||||
mockedFs.unlink.mockRejectedValue(new Error('not found'));
|
||||
mockedFs.symlink.mockResolvedValue(undefined);
|
||||
mockedFs.copyFile.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('writes insights under runtime output directory', async () => {
|
||||
const generator = new StaticInsightGenerator(mockConfig);
|
||||
const generateInsights = vi.fn().mockResolvedValue({});
|
||||
const renderInsightHTML = vi.fn().mockResolvedValue('<html>ok</html>');
|
||||
|
||||
(
|
||||
generator as unknown as {
|
||||
dataProcessor: { generateInsights: typeof generateInsights };
|
||||
}
|
||||
).dataProcessor = { generateInsights };
|
||||
(
|
||||
generator as unknown as {
|
||||
templateRenderer: { renderInsightHTML: typeof renderInsightHTML };
|
||||
}
|
||||
).templateRenderer = { renderInsightHTML };
|
||||
|
||||
const projectsDir = path.resolve(
|
||||
'workspace',
|
||||
'project-a',
|
||||
'.qwen',
|
||||
'projects',
|
||||
);
|
||||
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
|
||||
const facetsDir = path.join(outputDir, 'facets');
|
||||
const expectedOutputPath = path.join(outputDir, 'insight-2026-03-05.html');
|
||||
|
||||
const outputPath = await generator.generateStaticInsight(projectsDir);
|
||||
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith(outputDir, { recursive: true });
|
||||
expect(mockedFs.mkdir).toHaveBeenCalledWith(facetsDir, { recursive: true });
|
||||
expect(generateInsights).toHaveBeenCalledWith(
|
||||
projectsDir,
|
||||
facetsDir,
|
||||
undefined,
|
||||
);
|
||||
expect(renderInsightHTML).toHaveBeenCalledWith({});
|
||||
expect(mockedFs.writeFile).toHaveBeenCalledWith(
|
||||
expectedOutputPath,
|
||||
'<html>ok</html>',
|
||||
'utf-8',
|
||||
);
|
||||
expect(outputPath).toBe(expectedOutputPath);
|
||||
});
|
||||
});
|
||||
|
|
@ -6,7 +6,6 @@
|
|||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { DataProcessor } from './DataProcessor.js';
|
||||
import { TemplateRenderer } from './TemplateRenderer.js';
|
||||
import type {
|
||||
|
|
@ -14,7 +13,7 @@ import type {
|
|||
InsightProgressCallback,
|
||||
} from '../types/StaticInsightTypes.js';
|
||||
|
||||
import { updateSymlink, type Config } from '@qwen-code/qwen-code-core';
|
||||
import { updateSymlink, Storage, type Config } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export class StaticInsightGenerator {
|
||||
private dataProcessor: DataProcessor;
|
||||
|
|
@ -27,7 +26,7 @@ export class StaticInsightGenerator {
|
|||
|
||||
// Ensure the output directory exists
|
||||
private async ensureOutputDirectory(): Promise<string> {
|
||||
const outputDir = path.join(os.homedir(), '.qwen', 'insights');
|
||||
const outputDir = path.join(Storage.getRuntimeBaseDir(), 'insights');
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
return outputDir;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ describe('ShellProcessor', () => {
|
|||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getAllowedTools: vi.fn().mockReturnValue([]),
|
||||
getPermissionsAllow: vi.fn().mockReturnValue([]),
|
||||
// Default: no permission manager (tests that need one set it explicitly)
|
||||
getPermissionManager: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
|
|
@ -206,9 +208,11 @@ describe('ShellProcessor', () => {
|
|||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
(mockConfig.getAllowedTools as Mock).mockReturnValue([
|
||||
'ShellTool(rm -rf /)',
|
||||
]);
|
||||
// Simulate allowedTools being pre-merged into permissionsAllow by Config,
|
||||
// so PermissionManager returns 'allow' for this command.
|
||||
(mockConfig.getPermissionManager as Mock).mockReturnValue({
|
||||
isCommandAllowed: (_cmd: string) => 'allow',
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,14 +7,12 @@
|
|||
import {
|
||||
ApprovalMode,
|
||||
checkCommandPermissions,
|
||||
doesToolInvocationMatch,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
ShellExecutionService,
|
||||
flatMapTextParts,
|
||||
checkArgumentSafety,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { CommandContext } from '../../ui/commands/types.js';
|
||||
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||
|
|
@ -136,15 +134,12 @@ export class ShellProcessor implements IPromptProcessor {
|
|||
// Security check on the final, escaped command string.
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
checkCommandPermissions(command, config, sessionShellAllowlist);
|
||||
const allowedTools = config.getAllowedTools() || [];
|
||||
const invocation = {
|
||||
params: { command },
|
||||
} as AnyToolInvocation;
|
||||
const isAllowedBySettings = doesToolInvocationMatch(
|
||||
'run_shell_command',
|
||||
invocation,
|
||||
allowedTools,
|
||||
);
|
||||
|
||||
// Determine if this command is explicitly auto-approved via PermissionManager
|
||||
const pm = config.getPermissionManager?.();
|
||||
const isAllowedBySettings = pm
|
||||
? pm.isCommandAllowed(command) === 'allow'
|
||||
: false;
|
||||
|
||||
if (!allAllowed) {
|
||||
if (isHardDenial) {
|
||||
|
|
|
|||
|
|
@ -268,7 +268,7 @@ describe('AppContainer State Management', () => {
|
|||
// Mock config's getTargetDir to return consistent workspace directory
|
||||
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
|
||||
|
||||
// Mock GeminiClient to prevent unhandled errors from TaskTool.refreshSubagents
|
||||
// Mock GeminiClient to prevent unhandled errors from AgentTool.refreshSubagents
|
||||
const mockGeminiClient: Partial<GeminiClient> = {
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
setTools: vi.fn().mockResolvedValue(undefined),
|
||||
|
|
@ -278,7 +278,7 @@ describe('AppContainer State Management', () => {
|
|||
mockGeminiClient as GeminiClient,
|
||||
);
|
||||
|
||||
// Mock SubagentManager to prevent errors during TaskTool initialization
|
||||
// Mock SubagentManager to prevent errors during AgentTool initialization
|
||||
const mockSubagentManager: Partial<SubagentManager> = {
|
||||
listSubagents: vi.fn().mockResolvedValue([]),
|
||||
addChangeListener: vi.fn(),
|
||||
|
|
|
|||
|
|
@ -240,6 +240,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
|
||||
useCodingPlanUpdates(settings, config, historyManager.addItem);
|
||||
|
||||
const [isTrustDialogOpen, setTrustDialogOpen] = useState(false);
|
||||
const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []);
|
||||
const closeTrustDialog = useCallback(() => setTrustDialogOpen(false), []);
|
||||
|
||||
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
|
||||
const openPermissionsDialog = useCallback(
|
||||
() => setPermissionsDialogOpen(true),
|
||||
|
|
@ -550,6 +554,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openEditorDialog,
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
openTrustDialog,
|
||||
openArenaDialog,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
|
|
@ -578,6 +583,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openArenaDialog,
|
||||
setDebugMessage,
|
||||
dispatchExtensionStateUpdate,
|
||||
openTrustDialog,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
|
|
@ -1383,6 +1389,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isTrustDialogOpen ||
|
||||
activeArenaDialog !== null ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthDialogOpen ||
|
||||
|
|
@ -1434,6 +1441,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isTrustDialogOpen,
|
||||
activeArenaDialog,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
|
|
@ -1530,6 +1538,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isTrustDialogOpen,
|
||||
activeArenaDialog,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
|
|
@ -1633,6 +1642,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeArenaDialog,
|
||||
handleArenaModelsSelected,
|
||||
dismissCodingPlanUpdate,
|
||||
closeTrustDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
@ -1685,6 +1695,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
closeArenaDialog,
|
||||
handleArenaModelsSelected,
|
||||
dismissCodingPlanUpdate,
|
||||
closeTrustDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
66
packages/cli/src/ui/commands/insightCommand.test.ts
Normal file
66
packages/cli/src/ui/commands/insightCommand.test.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Code
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import path from 'path';
|
||||
import open from 'open';
|
||||
import { Storage } from '@qwen-code/qwen-code-core';
|
||||
import { insightCommand } from './insightCommand.js';
|
||||
import type { CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
const mockGenerateStaticInsight = vi.fn();
|
||||
|
||||
vi.mock('../../services/insight/generators/StaticInsightGenerator.js', () => ({
|
||||
StaticInsightGenerator: vi.fn(() => ({
|
||||
generateStaticInsight: mockGenerateStaticInsight,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('open', () => ({
|
||||
default: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('insightCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
Storage.setRuntimeBaseDir(path.resolve('runtime-output'));
|
||||
mockGenerateStaticInsight.mockResolvedValue(
|
||||
path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'),
|
||||
);
|
||||
vi.mocked(open).mockResolvedValue(undefined as never);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {} as CommandContext['services']['config'],
|
||||
},
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
setPendingItem: vi.fn(),
|
||||
setDebugMessage: vi.fn(),
|
||||
},
|
||||
} as unknown as CommandContext);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Storage.setRuntimeBaseDir(null);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses runtime base dir to locate projects directory', async () => {
|
||||
if (!insightCommand.action) {
|
||||
throw new Error('insight command must have action');
|
||||
}
|
||||
|
||||
await insightCommand.action(mockContext, '');
|
||||
|
||||
expect(mockGenerateStaticInsight).toHaveBeenCalledWith(
|
||||
path.join(Storage.getRuntimeBaseDir(), 'projects'),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -10,9 +10,8 @@ import { MessageType } from '../types.js';
|
|||
import type { HistoryItemInsightProgress } from '../types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { join } from 'path';
|
||||
import os from 'os';
|
||||
import { StaticInsightGenerator } from '../../services/insight/generators/StaticInsightGenerator.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { createDebugLogger, Storage } from '@qwen-code/qwen-code-core';
|
||||
import open from 'open';
|
||||
|
||||
const logger = createDebugLogger('DataProcessor');
|
||||
|
|
@ -29,7 +28,7 @@ export const insightCommand: SlashCommand = {
|
|||
try {
|
||||
context.ui.setDebugMessage(t('Generating insights...'));
|
||||
|
||||
const projectsDir = join(os.homedir(), '.qwen', 'projects');
|
||||
const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects');
|
||||
if (!context.services.config) {
|
||||
throw new Error('Config service is not available');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe('permissionsCommand', () => {
|
|||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(permissionsCommand.name).toBe('permissions');
|
||||
expect(permissionsCommand.description).toBe('Manage folder trust settings');
|
||||
expect(permissionsCommand.description).toBe('Manage permission rules');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { t } from '../../i18n/index.js';
|
|||
export const permissionsCommand: SlashCommand = {
|
||||
name: 'permissions',
|
||||
get description() {
|
||||
return t('Manage folder trust settings');
|
||||
return t('Manage permission rules');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
|
|
|
|||
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal file
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { trustCommand } from './trustCommand.js';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('trustCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(trustCommand.name).toBe('trust');
|
||||
expect(trustCommand.description).toBe('Manage folder trust settings');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
expect(trustCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should return an action to open the trust dialog', () => {
|
||||
const actionResult = trustCommand.action?.(mockContext, '');
|
||||
expect(actionResult).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/trustCommand.ts
Normal file
21
packages/cli/src/ui/commands/trustCommand.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const trustCommand: SlashCommand = {
|
||||
name: 'trust',
|
||||
get description() {
|
||||
return t('Manage folder trust settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
}),
|
||||
};
|
||||
|
|
@ -150,6 +150,7 @@ export interface OpenDialogActionReturn {
|
|||
| 'model'
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'trust'
|
||||
| 'permissions'
|
||||
| 'approval-mode'
|
||||
| 'resume'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal file
986
packages/cli/src/ui/components/PermissionsDialog.tsx
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as nodePath from 'node:path';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { Colors } from '../colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import type {
|
||||
PermissionManager,
|
||||
RuleWithSource,
|
||||
RuleType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { isPathWithinRoot } from '@qwen-code/qwen-code-core';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type TabId = 'allow' | 'ask' | 'deny' | 'workspace';
|
||||
|
||||
interface Tab {
|
||||
id: TabId;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Internal views for the dialog state machine. */
|
||||
type DialogView =
|
||||
| 'rule-list' // main rule list view
|
||||
| 'add-rule-input' // text input for new rule
|
||||
| 'add-rule-scope' // scope selector after entering a rule
|
||||
| 'delete-confirm' // confirm rule deletion
|
||||
| 'ws-dir-list' // workspace directory list
|
||||
| 'ws-add-dir-input' // text input for adding a directory
|
||||
| 'ws-remove-confirm'; // confirm directory removal
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scope items (matches Claude Code screenshot layout)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PermScopeItem {
|
||||
label: string;
|
||||
description: string;
|
||||
value: SettingScope;
|
||||
key: string;
|
||||
}
|
||||
|
||||
function getPermScopeItems(): PermScopeItem[] {
|
||||
return [
|
||||
{
|
||||
label: t('Project settings'),
|
||||
description: t('Checked in at .qwen/settings.json'),
|
||||
value: SettingScope.Workspace,
|
||||
key: 'project',
|
||||
},
|
||||
{
|
||||
label: t('User settings'),
|
||||
description: t('Saved in at ~/.qwen/settings.json'),
|
||||
value: SettingScope.User,
|
||||
key: 'user',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function getTabs(): Tab[] {
|
||||
return [
|
||||
{
|
||||
id: 'allow',
|
||||
label: t('Allow'),
|
||||
description: t("Qwen Code won't ask before using allowed tools."),
|
||||
},
|
||||
{
|
||||
id: 'ask',
|
||||
label: t('Ask'),
|
||||
description: t('Qwen Code will ask before using these tools.'),
|
||||
},
|
||||
{
|
||||
id: 'deny',
|
||||
label: t('Deny'),
|
||||
description: t('Qwen Code is not allowed to use denied tools.'),
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: t('Workspace'),
|
||||
description: t('Manage trusted directories for this workspace.'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function describeRule(raw: string): string {
|
||||
const match = raw.match(/^([^(]+?)(?:\((.+)\))?$/);
|
||||
if (!match) return raw;
|
||||
const toolName = match[1]!.trim();
|
||||
const specifier = match[2]?.trim();
|
||||
if (!specifier) {
|
||||
return t('Any use of the {{tool}} tool', { tool: toolName });
|
||||
}
|
||||
return t("{{tool}} commands matching '{{pattern}}'", {
|
||||
tool: toolName,
|
||||
pattern: specifier,
|
||||
});
|
||||
}
|
||||
|
||||
function scopeLabel(scope: string): string {
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
return t('From user settings');
|
||||
case 'workspace':
|
||||
return t('From project settings');
|
||||
case 'session':
|
||||
return t('From session');
|
||||
default:
|
||||
return scope;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PermissionsDialogProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PermissionsDialog({
|
||||
onExit,
|
||||
}: PermissionsDialogProps): React.JSX.Element {
|
||||
const config = useConfig();
|
||||
const settings = useSettings();
|
||||
const pm = config.getPermissionManager?.() as PermissionManager | null;
|
||||
|
||||
// --- Tab state ---
|
||||
const tabs = useMemo(() => getTabs(), []);
|
||||
const [activeTabIndex, setActiveTabIndex] = useState(0);
|
||||
const activeTab = tabs[activeTabIndex]!;
|
||||
|
||||
// --- Rule list state ---
|
||||
const [allRules, setAllRules] = useState<RuleWithSource[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isSearchActive, setIsSearchActive] = useState(false);
|
||||
|
||||
// --- Dialog view state machine ---
|
||||
const [view, setView] = useState<DialogView>('rule-list');
|
||||
const [newRuleInput, setNewRuleInput] = useState('');
|
||||
const [pendingRuleText, setPendingRuleText] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleWithSource | null>(null);
|
||||
|
||||
// --- Workspace directory state ---
|
||||
const workspaceContext = config.getWorkspaceContext();
|
||||
const [newDirInput, setNewDirInput] = useState('');
|
||||
const [dirInputError, setDirInputError] = useState('');
|
||||
const [dirInputRemountKey, setDirInputRemountKey] = useState(0);
|
||||
const [completionIndex, setCompletionIndex] = useState(0);
|
||||
const [removeDirTarget, setRemoveDirTarget] = useState<string | null>(null);
|
||||
const [dirRefreshKey, setDirRefreshKey] = useState(0);
|
||||
|
||||
// Refresh rules from PermissionManager
|
||||
const refreshRules = useCallback(() => {
|
||||
if (pm) {
|
||||
setAllRules(pm.listRules());
|
||||
}
|
||||
}, [pm]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshRules();
|
||||
}, [refreshRules]);
|
||||
|
||||
// --- Workspace directory helpers ---
|
||||
const directories = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
dirRefreshKey; // dependency to trigger re-computation
|
||||
return workspaceContext.getDirectories();
|
||||
}, [workspaceContext, dirRefreshKey]);
|
||||
|
||||
const initialDirs = useMemo(
|
||||
() => new Set(workspaceContext.getInitialDirectories()),
|
||||
[workspaceContext],
|
||||
);
|
||||
|
||||
// Filesystem completions based on current input
|
||||
const dirCompletions = useMemo(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return [];
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const endsWithSep =
|
||||
expanded.endsWith('/') || expanded.endsWith(nodePath.sep);
|
||||
const searchDir = endsWithSep ? expanded : nodePath.dirname(expanded);
|
||||
const prefix = endsWithSep ? '' : nodePath.basename(expanded);
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(searchDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
e.name.startsWith(prefix) &&
|
||||
!e.name.startsWith('.'),
|
||||
)
|
||||
.map((e) => nodePath.join(searchDir, e.name))
|
||||
.slice(0, 6);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [newDirInput]);
|
||||
|
||||
const handleDirInputChange = useCallback(
|
||||
(text: string) => {
|
||||
setNewDirInput(text);
|
||||
if (dirInputError) setDirInputError('');
|
||||
},
|
||||
[dirInputError],
|
||||
);
|
||||
|
||||
// Reset selection to first item whenever the completions list changes
|
||||
useEffect(() => {
|
||||
setCompletionIndex(0);
|
||||
}, [dirCompletions]);
|
||||
|
||||
const handleDirTabComplete = useCallback(() => {
|
||||
const selected = dirCompletions[completionIndex] ?? dirCompletions[0];
|
||||
if (selected) {
|
||||
setNewDirInput(selected + '/');
|
||||
setDirInputRemountKey((k) => k + 1);
|
||||
}
|
||||
}, [dirCompletions, completionIndex]);
|
||||
|
||||
const handleDirCompletionUp = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex(
|
||||
(prev) => (prev - 1 + dirCompletions.length) % dirCompletions.length,
|
||||
);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const handleDirCompletionDown = useCallback(() => {
|
||||
if (dirCompletions.length === 0) return;
|
||||
setCompletionIndex((prev) => (prev + 1) % dirCompletions.length);
|
||||
}, [dirCompletions.length]);
|
||||
|
||||
const dirListItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
}> = [];
|
||||
// 'Add directory…' always FIRST
|
||||
items.push({
|
||||
label: t('Add directory…'),
|
||||
value: '__add_dir__',
|
||||
key: '__add_dir__',
|
||||
});
|
||||
// Only show non-initial (runtime-added) directories in the selectable list
|
||||
for (const dir of directories) {
|
||||
if (!initialDirs.has(dir)) {
|
||||
items.push({
|
||||
label: dir,
|
||||
value: dir,
|
||||
key: `dir-${dir}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}, [directories, initialDirs]);
|
||||
|
||||
const handleDirListSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__add_dir__') {
|
||||
setNewDirInput('');
|
||||
setView('ws-add-dir-input');
|
||||
return;
|
||||
}
|
||||
// Selecting a directory → offer to remove if not initial
|
||||
if (!initialDirs.has(value)) {
|
||||
setRemoveDirTarget(value);
|
||||
setView('ws-remove-confirm');
|
||||
}
|
||||
},
|
||||
[initialDirs],
|
||||
);
|
||||
|
||||
const handleAddDirSubmit = useCallback(() => {
|
||||
const trimmed = newDirInput.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const absoluteExpanded = nodePath.isAbsolute(expanded)
|
||||
? expanded
|
||||
: nodePath.resolve(expanded);
|
||||
|
||||
// Existence & type checks
|
||||
if (!fs.existsSync(absoluteExpanded)) {
|
||||
setDirInputError(t('Directory does not exist.'));
|
||||
return;
|
||||
}
|
||||
if (!fs.statSync(absoluteExpanded).isDirectory()) {
|
||||
setDirInputError(t('Path is not a directory.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Resolve real path to match what workspaceContext stores
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = fs.realpathSync(absoluteExpanded);
|
||||
} catch {
|
||||
resolved = absoluteExpanded;
|
||||
}
|
||||
|
||||
// Validate: exact duplicate
|
||||
if ((directories as string[]).includes(resolved)) {
|
||||
setDirInputError(t('This directory is already in the workspace.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: is a subdirectory of an existing workspace directory
|
||||
for (const existingDir of directories) {
|
||||
if (isPathWithinRoot(resolved, existingDir)) {
|
||||
setDirInputError(
|
||||
t('Already covered by existing directory: {{dir}}', {
|
||||
dir: existingDir,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setDirInputError('');
|
||||
|
||||
// Add to workspace context (already validated)
|
||||
workspaceContext.addDirectory(resolved);
|
||||
|
||||
// Persist directly to project (Workspace) settings
|
||||
const key = 'context.includeDirectories';
|
||||
const currentDirs = (settings.merged as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const existingDirs = currentDirs?.['includeDirectories'] ?? [];
|
||||
if (!existingDirs.includes(resolved)) {
|
||||
settings.setValue(SettingScope.Workspace, key, [
|
||||
...existingDirs,
|
||||
resolved,
|
||||
]);
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setView('ws-dir-list');
|
||||
setNewDirInput('');
|
||||
}, [newDirInput, directories, workspaceContext, settings]);
|
||||
|
||||
const handleRemoveDirConfirm = useCallback(() => {
|
||||
if (!removeDirTarget) return;
|
||||
|
||||
// Remove from workspace context
|
||||
workspaceContext.removeDirectory(removeDirTarget);
|
||||
|
||||
// Remove from settings (try both scopes)
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const contextSection = (scopeSettings as Record<string, unknown>)[
|
||||
'context'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const scopeDirs = contextSection?.['includeDirectories'];
|
||||
if (scopeDirs?.includes(removeDirTarget)) {
|
||||
const updated = scopeDirs.filter((d: string) => d !== removeDirTarget);
|
||||
settings.setValue(scope, 'context.includeDirectories', updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setDirRefreshKey((k) => k + 1);
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
}, [removeDirTarget, workspaceContext, settings]);
|
||||
|
||||
// Filter rules for current tab
|
||||
const currentTabRules = useMemo(() => {
|
||||
if (activeTab.id === 'workspace') return [];
|
||||
return allRules.filter((r) => r.type === activeTab.id);
|
||||
}, [allRules, activeTab.id]);
|
||||
|
||||
// Search-filtered rules
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchQuery.trim()) return currentTabRules;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return currentTabRules.filter(
|
||||
(r) =>
|
||||
r.rule.raw.toLowerCase().includes(q) ||
|
||||
r.rule.toolName.toLowerCase().includes(q),
|
||||
);
|
||||
}, [currentTabRules, searchQuery]);
|
||||
|
||||
// Build radio items: "Add a new rule..." + filtered rules
|
||||
const listItems = useMemo(() => {
|
||||
const items: Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
key: string;
|
||||
}> = [
|
||||
{
|
||||
label: t('Add a new rule…'),
|
||||
value: '__add__',
|
||||
key: '__add__',
|
||||
},
|
||||
];
|
||||
for (const r of filteredRules) {
|
||||
items.push({
|
||||
label: `${r.rule.raw}`,
|
||||
value: r.rule.raw,
|
||||
key: `${r.type}-${r.scope}-${r.rule.raw}`,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [filteredRules]);
|
||||
|
||||
// --- Action handlers ---
|
||||
|
||||
const handleTabCycle = useCallback(
|
||||
(direction: 1 | -1) => {
|
||||
const newIndex = (activeTabIndex + direction + tabs.length) % tabs.length;
|
||||
setActiveTabIndex(newIndex);
|
||||
setSearchQuery('');
|
||||
setIsSearchActive(false);
|
||||
setDirInputError('');
|
||||
// Set the appropriate default view for each tab
|
||||
const newTab = tabs[newIndex]!;
|
||||
setView(newTab.id === 'workspace' ? 'ws-dir-list' : 'rule-list');
|
||||
},
|
||||
[activeTabIndex, tabs],
|
||||
);
|
||||
|
||||
const handleListSelect = useCallback(
|
||||
(value: string) => {
|
||||
if (value === '__add__') {
|
||||
setNewRuleInput('');
|
||||
setView('add-rule-input');
|
||||
return;
|
||||
}
|
||||
// Selecting an existing rule → offer to delete
|
||||
const found = filteredRules.find((r) => r.rule.raw === value);
|
||||
if (found) {
|
||||
setDeleteTarget(found);
|
||||
setView('delete-confirm');
|
||||
}
|
||||
},
|
||||
[filteredRules],
|
||||
);
|
||||
|
||||
const handleAddRuleSubmit = useCallback(() => {
|
||||
const trimmed = newRuleInput.trim();
|
||||
if (!trimmed) return;
|
||||
setPendingRuleText(trimmed);
|
||||
setView('add-rule-scope');
|
||||
}, [newRuleInput]);
|
||||
|
||||
const handleScopeSelect = useCallback(
|
||||
(scope: SettingScope) => {
|
||||
if (!pm || activeTab.id === 'workspace') return;
|
||||
const ruleType = activeTab.id as RuleType;
|
||||
|
||||
// Add to PermissionManager in-memory
|
||||
pm.addPersistentRule(pendingRuleText, ruleType);
|
||||
|
||||
// Persist to settings file (with dedup)
|
||||
const key = `permissions.${ruleType}`;
|
||||
const perms = (settings.merged as Record<string, unknown>)[
|
||||
'permissions'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const currentRules = perms?.[ruleType] ?? [];
|
||||
if (!currentRules.includes(pendingRuleText)) {
|
||||
settings.setValue(scope, key, [...currentRules, pendingRuleText]);
|
||||
}
|
||||
|
||||
// Refresh and go back
|
||||
refreshRules();
|
||||
setView('rule-list');
|
||||
setPendingRuleText('');
|
||||
},
|
||||
[pm, activeTab.id, pendingRuleText, settings, refreshRules],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!pm || !deleteTarget) return;
|
||||
const ruleType = deleteTarget.type;
|
||||
|
||||
// Remove from PermissionManager in-memory
|
||||
pm.removePersistentRule(deleteTarget.rule.raw, ruleType);
|
||||
|
||||
// Persist removal — find and remove from settings
|
||||
// We try both User and Workspace scopes
|
||||
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const perms = (scopeSettings as Record<string, unknown>)[
|
||||
'permissions'
|
||||
] as Record<string, string[]> | undefined;
|
||||
const scopeRules = perms?.[ruleType];
|
||||
if (scopeRules?.includes(deleteTarget.rule.raw)) {
|
||||
const updated = scopeRules.filter(
|
||||
(r: string) => r !== deleteTarget.rule.raw,
|
||||
);
|
||||
settings.setValue(scope, `permissions.${ruleType}`, updated);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
refreshRules();
|
||||
setDeleteTarget(null);
|
||||
setView('rule-list');
|
||||
}, [pm, deleteTarget, settings, refreshRules]);
|
||||
|
||||
// --- Keypress handling ---
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (view === 'rule-list') {
|
||||
if (key.name === 'escape') {
|
||||
if (isSearchActive && searchQuery) {
|
||||
setSearchQuery('');
|
||||
setIsSearchActive(false);
|
||||
} else {
|
||||
onExit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
handleTabCycle(1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' || key.name === 'left') {
|
||||
handleTabCycle(key.name === 'right' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
// Search input: backspace
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
if (searchQuery.length > 0) {
|
||||
setSearchQuery((prev) => prev.slice(0, -1));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Search input: printable characters
|
||||
if (
|
||||
key.sequence &&
|
||||
!key.ctrl &&
|
||||
!key.meta &&
|
||||
key.sequence.length === 1 &&
|
||||
key.sequence >= ' '
|
||||
) {
|
||||
setSearchQuery((prev) => prev + key.sequence);
|
||||
setIsSearchActive(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'add-rule-input') {
|
||||
if (key.name === 'escape') {
|
||||
setView('rule-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'add-rule-scope') {
|
||||
if (key.name === 'escape') {
|
||||
setView('add-rule-input');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'delete-confirm') {
|
||||
if (key.name === 'escape') {
|
||||
setDeleteTarget(null);
|
||||
setView('rule-list');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
handleDeleteConfirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Workspace tab views
|
||||
if (view === 'ws-dir-list') {
|
||||
if (key.name === 'escape') {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
handleTabCycle(1);
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' || key.name === 'left') {
|
||||
handleTabCycle(key.name === 'right' ? 1 : -1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-add-dir-input') {
|
||||
if (key.name === 'escape') {
|
||||
setDirInputError('');
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (view === 'ws-remove-confirm') {
|
||||
if (key.name === 'escape') {
|
||||
setRemoveDirTarget(null);
|
||||
setView('ws-dir-list');
|
||||
return;
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
handleRemoveDirConfirm();
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// --- Workspace tab: add directory input ---
|
||||
if (activeTab.id === 'workspace' && view === 'ws-add-dir-input') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text bold color={theme.text.accent}>
|
||||
{t('Add directory to workspace')}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text>{t('Enter the path to the directory:')}</Text>
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
marginTop={1}
|
||||
>
|
||||
<TextInput
|
||||
key={dirInputRemountKey}
|
||||
value={newDirInput}
|
||||
onChange={handleDirInputChange}
|
||||
onSubmit={handleAddDirSubmit}
|
||||
onTab={dirCompletions.length > 0 ? handleDirTabComplete : undefined}
|
||||
onUp={dirCompletions.length > 0 ? handleDirCompletionUp : undefined}
|
||||
onDown={
|
||||
dirCompletions.length > 0 ? handleDirCompletionDown : undefined
|
||||
}
|
||||
placeholder={t('Enter directory path…')}
|
||||
isActive={true}
|
||||
validationErrors={dirInputError ? [dirInputError] : []}
|
||||
/>
|
||||
</Box>
|
||||
{/* Filesystem completions: ↑/↓ to navigate, Tab to apply */}
|
||||
{dirCompletions.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1} paddingLeft={2}>
|
||||
{dirCompletions.map((completion, idx) => {
|
||||
const name = nodePath.basename(completion);
|
||||
const isSelected = idx === completionIndex;
|
||||
return (
|
||||
<Box key={completion}>
|
||||
<Text
|
||||
bold={isSelected}
|
||||
color={
|
||||
isSelected ? theme.text.primary : theme.text.secondary
|
||||
}
|
||||
>
|
||||
{`${name}/`}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{` directory`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Tab to complete · Enter to add · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: remove directory confirmation ---
|
||||
if (
|
||||
activeTab.id === 'workspace' &&
|
||||
view === 'ws-remove-confirm' &&
|
||||
removeDirTarget
|
||||
) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>{t('Remove directory?')}</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{removeDirTarget}</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>
|
||||
{t(
|
||||
'Are you sure you want to remove this directory from the workspace?',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Workspace tab: directory list (default) ---
|
||||
if (activeTab.id === 'workspace') {
|
||||
const initialDirArray = Array.from(initialDirs);
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{t(
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
|
||||
)}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
{/* Initial (non-removable) dirs: shown inline with dash, same visual level as list */}
|
||||
{initialDirArray.map((dir, idx) => (
|
||||
<Box key={dir} marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{'- '}</Text>
|
||||
<Text>{dir}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{idx === 0
|
||||
? t(' (Original working directory)')
|
||||
: t(' (from settings)')}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{/* Selectable list: runtime-added dirs + 'Add directory…' at end */}
|
||||
<RadioButtonSelect
|
||||
items={dirListItems}
|
||||
onSelect={handleDirListSelect}
|
||||
isFocused={view === 'ws-dir-list'}
|
||||
showNumbers={true}
|
||||
showScrollArrows={false}
|
||||
maxItemsToShow={15}
|
||||
/>
|
||||
<FooterHint view={view} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Render views ---
|
||||
|
||||
if (view === 'add-rule-input') {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Add {{type}} permission rule', { type: activeTab.id })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text wrap="wrap">
|
||||
{t(
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
|
||||
)}
|
||||
</Text>
|
||||
<Text>
|
||||
{t('e.g.,')} <Text bold>WebFetch</Text> {t('or')}{' '}
|
||||
<Text bold>Bash(ls:*)</Text>
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
<TextInput
|
||||
value={newRuleInput}
|
||||
onChange={setNewRuleInput}
|
||||
onSubmit={handleAddRuleSubmit}
|
||||
placeholder={t('Enter permission rule…')}
|
||||
isActive={true}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to submit · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'add-rule-scope') {
|
||||
const scopeItems = getPermScopeItems();
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Add {{type}} permission rule', { type: activeTab.id })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{pendingRuleText}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{describeRule(pendingRuleText)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>{t('Where should this rule be saved?')}</Text>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems.map((s) => ({
|
||||
label: `${s.label} ${s.description}`,
|
||||
value: s.value,
|
||||
key: s.key,
|
||||
}))}
|
||||
onSelect={handleScopeSelect}
|
||||
isFocused={true}
|
||||
showNumbers={true}
|
||||
/>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (view === 'delete-confirm' && deleteTarget) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
>
|
||||
<Text bold>
|
||||
{t('Delete {{type}} rule?', { type: deleteTarget.type })}
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text bold>{deleteTarget.rule.raw}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{describeRule(deleteTarget.rule.raw)}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{scopeLabel(deleteTarget.scope)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
<Text>
|
||||
{t('Are you sure you want to delete this permission rule?')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} marginLeft={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enter to confirm · Esc to cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Default: rule-list view ---
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<TabBar tabs={tabs} activeIndex={activeTabIndex} />
|
||||
<Text>{activeTab.description}</Text>
|
||||
{/* Search box */}
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderColor={theme.border.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={60}
|
||||
>
|
||||
<Text color={theme.text.accent}>{'> '}</Text>
|
||||
{searchQuery ? (
|
||||
<Text>{searchQuery}</Text>
|
||||
) : (
|
||||
<Text color={Colors.Gray}>{t('Search…')}</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box height={1} />
|
||||
{/* Rule list */}
|
||||
<RadioButtonSelect
|
||||
items={listItems}
|
||||
onSelect={handleListSelect}
|
||||
isFocused={view === 'rule-list'}
|
||||
showNumbers={true}
|
||||
showScrollArrows={false}
|
||||
maxItemsToShow={15}
|
||||
/>
|
||||
<FooterHint view={view} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabBar({
|
||||
tabs,
|
||||
activeIndex,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeIndex: number;
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{t('Permissions:')}{' '}
|
||||
</Text>
|
||||
{tabs.map((tab, i) => (
|
||||
<Box key={tab.id} marginRight={2}>
|
||||
{i === activeIndex ? (
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={theme.text.accent}
|
||||
color={theme.background.primary}
|
||||
>
|
||||
{` ${tab.label} `}
|
||||
</Text>
|
||||
) : (
|
||||
<Text color={theme.text.secondary}>{` ${tab.label} `}</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
<Text color={theme.text.secondary}>{t('(←/→ or tab to cycle)')}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FooterHint({ view }: { view: DialogView }): React.JSX.Element {
|
||||
if (view !== 'rule-list' && view !== 'ws-dir-list') return <></>;
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t(
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -33,13 +33,13 @@ describe('ShellConfirmationDialog', () => {
|
|||
expect(select).toContain('Yes, allow once');
|
||||
});
|
||||
|
||||
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
|
||||
it('calls onConfirm with ProceedAlwaysProject when "Always allow in this project" is selected', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<ShellConfirmationDialog request={request} />,
|
||||
);
|
||||
const select = lastFrame()!.toString();
|
||||
// Simulate selecting the second option
|
||||
expect(select).toContain('Yes, allow always for this session');
|
||||
expect(select).toContain('Always allow in this project');
|
||||
});
|
||||
|
||||
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
|
||||
|
|
|
|||
|
|
@ -57,9 +57,14 @@ export const ShellConfirmationDialog: React.FC<
|
|||
key: 'Yes, allow once',
|
||||
},
|
||||
{
|
||||
label: t('Yes, allow always for this session'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always for this session',
|
||||
label: t('Always allow in this project'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
label: t('Always allow for this user'),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
},
|
||||
{
|
||||
label: t('No (esc)'),
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { TrustDialog } from './TrustDialog.js';
|
||||
import { TrustLevel } from '../../config/trustedFolders.js';
|
||||
import { waitFor, act } from '@testing-library/react';
|
||||
import * as processUtils from '../../utils/processUtils.js';
|
||||
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
|
||||
import { useTrustModify } from '../hooks/useTrustModify.js';
|
||||
|
||||
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
|
||||
// Hoist mocks for dependencies of the useTrustModify hook
|
||||
const mockedCwd = vi.hoisted(() => vi.fn());
|
||||
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
|
||||
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
|
||||
|
|
@ -39,16 +39,16 @@ vi.mock('../../config/trustedFolders.js', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePermissionsModifyTrust.js');
|
||||
vi.mock('../hooks/useTrustModify.js');
|
||||
|
||||
describe('PermissionsModifyTrustDialog', () => {
|
||||
describe('TrustDialog', () => {
|
||||
let mockUpdateTrustLevel: Mock;
|
||||
let mockCommitTrustLevelChange: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateTrustLevel = vi.fn();
|
||||
mockCommitTrustLevelChange = vi.fn();
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -66,7 +66,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
it('should render the main dialog with current trust level', async () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -77,7 +77,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should display the inherited trust note from parent', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: true,
|
||||
|
|
@ -88,7 +88,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
isFolderTrustEnabled: true,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -99,7 +99,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should display the inherited trust note from IDE', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -110,7 +110,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
isFolderTrustEnabled: true,
|
||||
});
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={vi.fn()} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -123,7 +123,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
it('should call onExit when escape is pressed', async () => {
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -141,7 +141,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
const mockRelaunchApp = vi
|
||||
.spyOn(processUtils, 'relaunchApp')
|
||||
.mockResolvedValue(undefined);
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -154,7 +154,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -171,7 +171,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
});
|
||||
|
||||
it('should not commit when escape is pressed during restart prompt', async () => {
|
||||
vi.mocked(usePermissionsModifyTrust).mockReturnValue({
|
||||
vi.mocked(useTrustModify).mockReturnValue({
|
||||
cwd: '/test/dir',
|
||||
currentTrustLevel: TrustLevel.DO_NOT_TRUST,
|
||||
isInheritedTrustFromParent: false,
|
||||
|
|
@ -184,7 +184,7 @@ describe('PermissionsModifyTrustDialog', () => {
|
|||
|
||||
const onExit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<PermissionsModifyTrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
<TrustDialog onExit={onExit} addItem={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(lastFrame()).not.toContain('Loading...'));
|
||||
|
|
@ -8,13 +8,13 @@ import { Box, Text } from 'ink';
|
|||
import type React from 'react';
|
||||
import { TrustLevel } from '../../config/trustedFolders.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
|
||||
import { useTrustModify } from '../hooks/useTrustModify.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { relaunchApp } from '../../utils/processUtils.js';
|
||||
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
|
||||
interface PermissionsModifyTrustDialogProps {
|
||||
interface TrustDialogProps {
|
||||
onExit: () => void;
|
||||
addItem: UseHistoryManagerReturn['addItem'];
|
||||
}
|
||||
|
|
@ -37,10 +37,10 @@ const TRUST_LEVEL_ITEMS = [
|
|||
},
|
||||
];
|
||||
|
||||
export function PermissionsModifyTrustDialog({
|
||||
export function TrustDialog({
|
||||
onExit,
|
||||
addItem,
|
||||
}: PermissionsModifyTrustDialogProps): React.JSX.Element {
|
||||
}: TrustDialogProps): React.JSX.Element {
|
||||
const {
|
||||
cwd,
|
||||
currentTrustLevel,
|
||||
|
|
@ -49,7 +49,7 @@ export function PermissionsModifyTrustDialog({
|
|||
needsRestart,
|
||||
updateTrustLevel,
|
||||
commitTrustLevelChange,
|
||||
} = usePermissionsModifyTrust(onExit, addItem);
|
||||
} = useTrustModify(onExit, addItem);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
|
|
@ -7,7 +7,7 @@ exports[`LoopDetectionConfirmation > renders correctly 1`] = `
|
|||
│ This can happen due to repetitive tool calls or other model behavior. Do you want to keep loop │
|
||||
│ detection enabled or disable it for this session? │
|
||||
│ │
|
||||
│ ● 1. Keep loop detection enabled (esc) │
|
||||
│ › 1. Keep loop detection enabled (esc) │
|
||||
│ 2. Disable loop detection for this session │
|
||||
│ │
|
||||
│ Note: To disable loop detection checks for all future sessions, set "model.skipLoopDetection" to │
|
||||
|
|
|
|||
|
|
@ -13,9 +13,10 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
|
|||
│ │
|
||||
│ Do you want to proceed? │
|
||||
│ │
|
||||
│ ● 1. Yes, allow once │
|
||||
│ 2. Yes, allow always for this session │
|
||||
│ 3. No (esc) │
|
||||
│ › 1. Yes, allow once │
|
||||
│ 2. Always allow in this project │
|
||||
│ 3. Always allow for this user │
|
||||
│ 4. No (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ exports[`ThemeDialog Snapshots > should render correctly in scope selector mode
|
|||
│ │
|
||||
│ > Apply To │
|
||||
│ │
|
||||
│ ● 1. User Settings │
|
||||
│ › 1. User Settings │
|
||||
│ 2. Workspace Settings │
|
||||
│ │
|
||||
│ (Use Enter to apply scope, Tab to go back) │
|
||||
|
|
@ -19,7 +19,7 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode
|
|||
│ > Select Theme Preview │
|
||||
│ ▲ ┌─────────────────────────────────────────────────┐ │
|
||||
│ 1. Qwen Light Light │ │ │
|
||||
│ ● 2. Qwen Dark Dark │ 1 # function │ │
|
||||
│ › 2. Qwen Dark Dark │ 1 # function │ │
|
||||
│ 3. ANSI Dark │ 2 def fibonacci(n): │ │
|
||||
│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │
|
||||
│ 5. Ayu Dark │ 4 for _ in range(n): │ │
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -138,17 +138,17 @@ describe('ToolConfirmationMessage', () => {
|
|||
{
|
||||
description: 'for exec confirmations',
|
||||
details: execConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
description: 'for info confirmations',
|
||||
details: infoConfirmationDetails,
|
||||
alwaysAllowText: 'Yes, allow always',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
{
|
||||
description: 'for mcp confirmations',
|
||||
details: mcpConfirmationDetails,
|
||||
alwaysAllowText: 'always allow',
|
||||
alwaysAllowText: 'Always allow in this project',
|
||||
},
|
||||
])('$description', ({ details, alwaysAllowText }) => {
|
||||
it('should show "allow always" when folder is trusted', () => {
|
||||
|
|
|
|||
|
|
@ -242,11 +242,19 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = executionProps.permissionRules?.length
|
||||
? ` [${executionProps.permissionRules.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, allow always ...'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always ...',
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
@ -315,11 +323,21 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel =
|
||||
'permissionRules' in infoProps &&
|
||||
(infoProps as { permissionRules?: string[] }).permissionRules?.length
|
||||
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, allow always'),
|
||||
value: ToolConfirmationOutcome.ProceedAlways,
|
||||
key: 'Yes, allow always',
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
@ -382,21 +400,19 @@ export const ToolConfirmationMessage: React.FC<
|
|||
value: ToolConfirmationOutcome.ProceedOnce,
|
||||
key: 'Yes, allow once',
|
||||
});
|
||||
if (isTrustedFolder) {
|
||||
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
|
||||
const rulesLabel = mcpProps.permissionRules?.length
|
||||
? ` [${mcpProps.permissionRules.join(', ')}]`
|
||||
: '';
|
||||
options.push({
|
||||
label: t('Yes, always allow tool "{{tool}}" from server "{{server}}"', {
|
||||
tool: mcpProps.toolName,
|
||||
server: mcpProps.serverName,
|
||||
}),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
|
||||
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
|
||||
label: t('Always allow in this project') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
key: 'Always allow in this project',
|
||||
});
|
||||
options.push({
|
||||
label: t('Yes, always allow all tools from server "{{server}}"', {
|
||||
server: mcpProps.serverName,
|
||||
}),
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
|
||||
label: t('Always allow for this user') + rulesLabel,
|
||||
value: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
key: 'Always allow for this user',
|
||||
});
|
||||
}
|
||||
options.push({
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
|||
import { TodoDisplay } from '../TodoDisplay.js';
|
||||
import type {
|
||||
TodoResultDisplay,
|
||||
TaskResultDisplay,
|
||||
AgentResultDisplay,
|
||||
PlanResultDisplay,
|
||||
AnsiOutput,
|
||||
Config,
|
||||
|
|
@ -50,7 +50,7 @@ type DisplayRendererResult =
|
|||
| { type: 'plan'; data: PlanResultDisplay }
|
||||
| { type: 'string'; data: string }
|
||||
| { type: 'diff'; data: { fileDiff: string; fileName: string } }
|
||||
| { type: 'task'; data: TaskResultDisplay }
|
||||
| { type: 'task'; data: AgentResultDisplay }
|
||||
| { type: 'ansi'; data: AnsiOutput };
|
||||
|
||||
/**
|
||||
|
|
@ -98,7 +98,7 @@ const useResultDisplayRenderer = (
|
|||
) {
|
||||
return {
|
||||
type: 'task',
|
||||
data: resultDisplay as TaskResultDisplay,
|
||||
data: resultDisplay as AgentResultDisplay,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ const PlanResultRenderer: React.FC<{
|
|||
* Component to render subagent execution results
|
||||
*/
|
||||
const SubagentExecutionRenderer: React.FC<{
|
||||
data: TaskResultDisplay;
|
||||
data: AgentResultDisplay;
|
||||
availableHeight?: number;
|
||||
childWidth: number;
|
||||
config: Config;
|
||||
|
|
|
|||
|
|
@ -93,12 +93,12 @@ describe('BaseSelectionList', () => {
|
|||
expect(mockRenderItem).toHaveBeenCalledWith(items[0], expect.any(Object));
|
||||
});
|
||||
|
||||
it('should render the selection indicator (● or space) and layout', () => {
|
||||
it('should render the selection indicator (› or space) and layout', () => {
|
||||
const { lastFrame } = renderComponent({}, 0);
|
||||
const output = lastFrame();
|
||||
|
||||
// Use regex to assert the structure: Indicator + Whitespace + Number + Label
|
||||
expect(output).toMatch(/●\s+1\.\s+Item A/);
|
||||
expect(output).toMatch(/›\s+1\.\s+Item A/);
|
||||
expect(output).toMatch(/\s+2\.\s+Item B/);
|
||||
expect(output).toMatch(/\s+3\.\s+Item C/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export function BaseSelectionList<
|
|||
color={isSelected ? theme.status.success : theme.text.primary}
|
||||
aria-hidden
|
||||
>
|
||||
{isSelected ? '●' : ' '}
|
||||
{isSelected ? '›' : ' '}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type {
|
||||
TaskResultDisplay,
|
||||
AgentResultDisplay,
|
||||
AgentStatsSummary,
|
||||
Config,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -20,7 +20,7 @@ import { ToolConfirmationMessage } from '../../messages/ToolConfirmationMessage.
|
|||
export type DisplayMode = 'compact' | 'default' | 'verbose';
|
||||
|
||||
export interface AgentExecutionDisplayProps {
|
||||
data: TaskResultDisplay;
|
||||
data: AgentResultDisplay;
|
||||
availableHeight?: number;
|
||||
childWidth: number;
|
||||
config: Config;
|
||||
|
|
@ -28,7 +28,7 @@ export interface AgentExecutionDisplayProps {
|
|||
|
||||
const getStatusColor = (
|
||||
status:
|
||||
| TaskResultDisplay['status']
|
||||
| AgentResultDisplay['status']
|
||||
| 'executing'
|
||||
| 'success'
|
||||
| 'awaiting_approval',
|
||||
|
|
@ -50,7 +50,7 @@ const getStatusColor = (
|
|||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: TaskResultDisplay['status']) => {
|
||||
const getStatusText = (status: AgentResultDisplay['status']) => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'Running';
|
||||
|
|
@ -301,7 +301,7 @@ const TaskPromptSection: React.FC<{
|
|||
* Status dot component with similar height as text
|
||||
*/
|
||||
const StatusDot: React.FC<{
|
||||
status: TaskResultDisplay['status'];
|
||||
status: AgentResultDisplay['status'];
|
||||
}> = ({ status }) => (
|
||||
<Box marginLeft={1} marginRight={1}>
|
||||
<Text color={getStatusColor(status)}>●</Text>
|
||||
|
|
@ -312,7 +312,7 @@ const StatusDot: React.FC<{
|
|||
* Status indicator component
|
||||
*/
|
||||
const StatusIndicator: React.FC<{
|
||||
status: TaskResultDisplay['status'];
|
||||
status: AgentResultDisplay['status'];
|
||||
}> = ({ status }) => {
|
||||
const color = getStatusColor(status);
|
||||
const text = getStatusText(status);
|
||||
|
|
@ -323,7 +323,7 @@ const StatusIndicator: React.FC<{
|
|||
* Tool calls list - format consistent with ToolInfo in ToolMessage.tsx
|
||||
*/
|
||||
const ToolCallsList: React.FC<{
|
||||
toolCalls: TaskResultDisplay['toolCalls'];
|
||||
toolCalls: AgentResultDisplay['toolCalls'];
|
||||
displayMode: DisplayMode;
|
||||
}> = ({ toolCalls, displayMode }) => {
|
||||
const calls = toolCalls || [];
|
||||
|
|
@ -435,7 +435,7 @@ const ToolCallItem: React.FC<{
|
|||
* Execution summary details component
|
||||
*/
|
||||
const ExecutionSummaryDetails: React.FC<{
|
||||
data: TaskResultDisplay;
|
||||
data: AgentResultDisplay;
|
||||
displayMode: DisplayMode;
|
||||
}> = ({ data, displayMode: _displayMode }) => {
|
||||
const stats = data.executionSummary;
|
||||
|
|
@ -505,7 +505,7 @@ const ToolUsageStats: React.FC<{
|
|||
* Results section for completed executions - matches the clean layout from the image
|
||||
*/
|
||||
const ResultsSection: React.FC<{
|
||||
data: TaskResultDisplay;
|
||||
data: AgentResultDisplay;
|
||||
displayMode: DisplayMode;
|
||||
}> = ({ data, displayMode }) => (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ export interface UIState {
|
|||
quittingMessages: HistoryItem[] | null;
|
||||
isSettingsDialogOpen: boolean;
|
||||
isModelDialogOpen: boolean;
|
||||
isTrustDialogOpen: boolean;
|
||||
activeArenaDialog: ArenaDialogType;
|
||||
isPermissionsDialogOpen: boolean;
|
||||
isApprovalModeDialogOpen: boolean;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ const mockConfig = {
|
|||
},
|
||||
getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getAllowedTools: vi.fn(() => []),
|
||||
getPermissionsAllow: vi.fn(() => []),
|
||||
getContentGeneratorConfig: () => ({
|
||||
model: 'test-model',
|
||||
authType: 'gemini',
|
||||
|
|
@ -83,24 +83,14 @@ const mockTool = new MockTool({
|
|||
name: 'mockTool',
|
||||
displayName: 'Mock Tool',
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
});
|
||||
const mockToolWithLiveOutput = new MockTool({
|
||||
name: 'mockToolWithLiveOutput',
|
||||
displayName: 'Mock Tool With Live Output',
|
||||
description: 'A mock tool for testing',
|
||||
params: {},
|
||||
isOutputMarkdown: true,
|
||||
canUpdateOutput: true,
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
});
|
||||
let mockOnUserConfirmForToolConfirmation: Mock;
|
||||
const mockToolRequiresConfirmation = new MockTool({
|
||||
name: 'mockToolRequiresConfirmation',
|
||||
displayName: 'Mock Tool Requires Confirmation',
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
getDefaultPermission: () => Promise.resolve('ask' as any),
|
||||
getConfirmationDetails: vi.fn(),
|
||||
});
|
||||
|
||||
describe('useReactToolScheduler in YOLO Mode', () => {
|
||||
|
|
@ -112,7 +102,7 @@ describe('useReactToolScheduler in YOLO Mode', () => {
|
|||
setPendingHistoryItem = vi.fn();
|
||||
mockToolRegistry.getTool.mockClear();
|
||||
(mockToolRequiresConfirmation.execute as Mock).mockClear();
|
||||
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
|
||||
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
|
||||
|
||||
// IMPORTANT: Enable YOLO mode for this test suite
|
||||
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.YOLO);
|
||||
|
|
@ -218,17 +208,14 @@ describe('useReactToolScheduler', () => {
|
|||
|
||||
mockToolRegistry.getTool.mockClear();
|
||||
(mockTool.execute as Mock).mockClear();
|
||||
(mockTool.shouldConfirmExecute as Mock).mockClear();
|
||||
(mockToolWithLiveOutput.execute as Mock).mockClear();
|
||||
(mockToolWithLiveOutput.shouldConfirmExecute as Mock).mockClear();
|
||||
(mockToolRequiresConfirmation.execute as Mock).mockClear();
|
||||
(mockToolRequiresConfirmation.shouldConfirmExecute as Mock).mockClear();
|
||||
(mockToolRequiresConfirmation.getConfirmationDetails as Mock).mockClear();
|
||||
|
||||
mockOnUserConfirmForToolConfirmation = vi.fn();
|
||||
(
|
||||
mockToolRequiresConfirmation.shouldConfirmExecute as Mock
|
||||
mockToolRequiresConfirmation.getConfirmationDetails as Mock
|
||||
).mockImplementation(
|
||||
async (): Promise<ToolCallConfirmationDetails | null> =>
|
||||
async (): Promise<ToolCallConfirmationDetails> =>
|
||||
({
|
||||
onConfirm: mockOnUserConfirmForToolConfirmation,
|
||||
fileName: 'mockToolRequiresConfirmation.ts',
|
||||
|
|
@ -267,7 +254,6 @@ describe('useReactToolScheduler', () => {
|
|||
llmContent: 'Tool output',
|
||||
returnDisplay: 'Formatted tool output',
|
||||
} as ToolResult);
|
||||
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
||||
|
||||
const { result } = renderScheduler();
|
||||
const schedule = result.current[1];
|
||||
|
|
@ -352,10 +338,11 @@ describe('useReactToolScheduler', () => {
|
|||
expect(result.current[0]).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle error during shouldConfirmExecute', async () => {
|
||||
it('should handle error during getDefaultPermission', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
||||
const confirmError = new Error('Confirmation check failed');
|
||||
(mockTool.shouldConfirmExecute as Mock).mockRejectedValue(confirmError);
|
||||
const originalGetDefaultPermission = mockTool.getDefaultPermission;
|
||||
mockTool.getDefaultPermission = () => Promise.reject(confirmError);
|
||||
|
||||
const { result } = renderScheduler();
|
||||
const schedule = result.current[1];
|
||||
|
|
@ -385,11 +372,11 @@ describe('useReactToolScheduler', () => {
|
|||
}),
|
||||
]);
|
||||
expect(result.current[0]).toEqual([]);
|
||||
mockTool.getDefaultPermission = originalGetDefaultPermission;
|
||||
});
|
||||
|
||||
it('should handle error during execute', async () => {
|
||||
mockToolRegistry.getTool.mockReturnValue(mockTool);
|
||||
(mockTool.shouldConfirmExecute as Mock).mockResolvedValue(null);
|
||||
const execError = new Error('Execution failed');
|
||||
(mockTool.execute as Mock).mockRejectedValue(execError);
|
||||
|
||||
|
|
@ -532,7 +519,6 @@ describe('mapToDisplay', () => {
|
|||
name: 'testTool',
|
||||
displayName: 'Test Tool Display',
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
});
|
||||
|
||||
const baseResponse: ToolCallResponseInfo = {
|
||||
|
|
@ -767,7 +753,6 @@ describe('mapToDisplay', () => {
|
|||
displayName: baseTool.displayName,
|
||||
isOutputMarkdown: true,
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
});
|
||||
const toolCall2: ToolCall = {
|
||||
request: { ...baseRequest, callId: 'call2' },
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
@ -42,7 +42,7 @@ function getInitialTrustState(
|
|||
};
|
||||
}
|
||||
|
||||
export const usePermissionsModifyTrust = (
|
||||
export const useTrustModify = (
|
||||
onExit: () => void,
|
||||
addItem: UseHistoryManagerReturn['addItem'],
|
||||
) => {
|
||||
|
|
@ -181,10 +181,7 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats {
|
|||
let filePath: string;
|
||||
if (typeof display.fileName === 'string') {
|
||||
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
|
||||
filePath =
|
||||
(args?.['file_path'] as string) ||
|
||||
(args?.['absolute_path'] as string) ||
|
||||
display.fileName;
|
||||
filePath = (args?.['file_path'] as string) || display.fileName;
|
||||
} else {
|
||||
// Fallback if fileName is not a string
|
||||
filePath = 'unknown';
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ function formatToolDescription(
|
|||
const invocation = tool.build(args);
|
||||
return invocation.getDescription();
|
||||
} catch {
|
||||
// Fallback: use the description arg directly if available
|
||||
if (typeof args['description'] === 'string') {
|
||||
return args['description'];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||
import type {
|
||||
Config,
|
||||
SessionMetrics,
|
||||
TaskResultDisplay,
|
||||
AgentResultDisplay,
|
||||
ToolCallResponseInfo,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
|
|
@ -30,7 +30,7 @@ import {
|
|||
computeUsageFromMetrics,
|
||||
buildSystemMessage,
|
||||
createToolProgressHandler,
|
||||
createTaskToolProgressHandler,
|
||||
createAgentToolProgressHandler,
|
||||
functionResponsePartsToString,
|
||||
toolResultContent,
|
||||
} from './nonInteractiveHelpers.js';
|
||||
|
|
@ -731,7 +731,7 @@ describe('createToolProgressHandler', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('createTaskToolProgressHandler', () => {
|
||||
describe('createAgentToolProgressHandler', () => {
|
||||
let mockAdapter: JsonOutputAdapterInterface;
|
||||
let mockConfig: Config;
|
||||
|
||||
|
|
@ -751,13 +751,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should create handler that processes task tool calls', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -786,13 +786,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should emit tool_result when tool call completes', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -825,13 +825,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should not duplicate tool_use emissions', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -855,13 +855,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should not duplicate tool_result emissions', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -886,14 +886,14 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should handle status transitions from executing to completed', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
// First: executing state
|
||||
const executingDisplay: TaskResultDisplay = {
|
||||
const executingDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -910,7 +910,7 @@ describe('createTaskToolProgressHandler', () => {
|
|||
};
|
||||
|
||||
// Second: completed state
|
||||
const completedDisplay: TaskResultDisplay = {
|
||||
const completedDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -935,13 +935,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should emit error result for failed task status', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const runningDisplay: TaskResultDisplay = {
|
||||
const runningDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -950,7 +950,7 @@ describe('createTaskToolProgressHandler', () => {
|
|||
toolCalls: [],
|
||||
};
|
||||
|
||||
const failedDisplay: TaskResultDisplay = {
|
||||
const failedDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -971,13 +971,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should emit error result for cancelled task status', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const runningDisplay: TaskResultDisplay = {
|
||||
const runningDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -986,7 +986,7 @@ describe('createTaskToolProgressHandler', () => {
|
|||
toolCalls: [],
|
||||
};
|
||||
|
||||
const cancelledDisplay: TaskResultDisplay = {
|
||||
const cancelledDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -1006,7 +1006,7 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should not process non-task-execution displays', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
|
|
@ -1017,20 +1017,20 @@ describe('createTaskToolProgressHandler', () => {
|
|||
content: 'some content',
|
||||
};
|
||||
|
||||
handler('call-id', nonTaskDisplay as unknown as TaskResultDisplay);
|
||||
handler('call-id', nonTaskDisplay as unknown as AgentResultDisplay);
|
||||
|
||||
expect(mockAdapter.processSubagentToolCall).not.toHaveBeenCalled();
|
||||
expect(mockAdapter.emitToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle tool calls with failed status', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -1061,13 +1061,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
});
|
||||
|
||||
it('should handle tool calls without result content', () => {
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
mockAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
@ -1096,13 +1096,13 @@ describe('createTaskToolProgressHandler', () => {
|
|||
emitToolResult: vi.fn(),
|
||||
} as unknown as JsonOutputAdapterInterface;
|
||||
|
||||
const { handler } = createTaskToolProgressHandler(
|
||||
const { handler } = createAgentToolProgressHandler(
|
||||
mockConfig,
|
||||
'parent-tool-id',
|
||||
limitedAdapter,
|
||||
);
|
||||
|
||||
const taskDisplay: TaskResultDisplay = {
|
||||
const taskDisplay: AgentResultDisplay = {
|
||||
type: 'task_execution',
|
||||
subagentName: 'test-agent',
|
||||
taskDescription: 'Test task',
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
import type {
|
||||
Config,
|
||||
ToolResultDisplay,
|
||||
TaskResultDisplay,
|
||||
AgentResultDisplay,
|
||||
OutputUpdateHandler,
|
||||
ToolCallRequestInfo,
|
||||
ToolCallResponseInfo,
|
||||
|
|
@ -335,25 +335,25 @@ export function createToolProgressHandler(
|
|||
}
|
||||
|
||||
/**
|
||||
* Creates an output update handler specifically for Task tool subagent execution.
|
||||
* This handler monitors TaskResultDisplay updates and converts them to protocol messages
|
||||
* Creates an output update handler specifically for Agent tool subagent execution.
|
||||
* This handler monitors AgentResultDisplay updates and converts them to protocol messages
|
||||
* using the unified adapter's subagent APIs. All emitted messages will have parent_tool_use_id set to
|
||||
* the task tool's callId.
|
||||
* the agent tool's callId.
|
||||
*
|
||||
* @param config - Config instance for getting output format
|
||||
* @param taskToolCallId - The task tool's callId to use as parent_tool_use_id for all subagent messages
|
||||
* @param agentToolCallId - The agent tool's callId to use as parent_tool_use_id for all subagent messages
|
||||
* @param adapter - The unified adapter instance (JsonOutputAdapter or StreamJsonOutputAdapter)
|
||||
* @returns An object containing the output update handler
|
||||
*/
|
||||
export function createTaskToolProgressHandler(
|
||||
export function createAgentToolProgressHandler(
|
||||
config: Config,
|
||||
taskToolCallId: string,
|
||||
agentToolCallId: string,
|
||||
adapter: JsonOutputAdapterInterface,
|
||||
): {
|
||||
handler: OutputUpdateHandler;
|
||||
} {
|
||||
// Track previous TaskResultDisplay states per tool call to detect changes
|
||||
const previousTaskStates = new Map<string, TaskResultDisplay>();
|
||||
// Track previous AgentResultDisplay states per tool call to detect changes
|
||||
const previousTaskStates = new Map<string, AgentResultDisplay>();
|
||||
// Track which tool call IDs have already emitted tool_use to prevent duplicates
|
||||
const emittedToolUseIds = new Set<string>();
|
||||
// Track which tool call IDs have already emitted tool_result to prevent duplicates
|
||||
|
|
@ -366,7 +366,7 @@ export function createTaskToolProgressHandler(
|
|||
* @returns ToolCallRequestInfo object
|
||||
*/
|
||||
const buildRequest = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
): ToolCallRequestInfo => ({
|
||||
callId: toolCall.callId,
|
||||
name: toolCall.name,
|
||||
|
|
@ -383,7 +383,7 @@ export function createTaskToolProgressHandler(
|
|||
* @returns ToolCallResponseInfo object
|
||||
*/
|
||||
const buildResponse = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
): ToolCallResponseInfo => ({
|
||||
callId: toolCall.callId,
|
||||
error:
|
||||
|
|
@ -403,7 +403,7 @@ export function createTaskToolProgressHandler(
|
|||
* @returns True if the tool call has result content to emit
|
||||
*/
|
||||
const hasResultContent = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
): boolean => {
|
||||
// Check resultDisplay string
|
||||
if (
|
||||
|
|
@ -429,14 +429,14 @@ export function createTaskToolProgressHandler(
|
|||
* @param fallbackStatus - Optional fallback status if toolCall.status should be overridden
|
||||
*/
|
||||
const emitToolUseIfNeeded = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
fallbackStatus?: 'executing' | 'awaiting_approval',
|
||||
): void => {
|
||||
if (emittedToolUseIds.has(toolCall.callId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolCallToEmit: NonNullable<TaskResultDisplay['toolCalls']>[number] =
|
||||
const toolCallToEmit: NonNullable<AgentResultDisplay['toolCalls']>[number] =
|
||||
fallbackStatus
|
||||
? {
|
||||
...toolCall,
|
||||
|
|
@ -449,7 +449,7 @@ export function createTaskToolProgressHandler(
|
|||
toolCallToEmit.status === 'awaiting_approval'
|
||||
) {
|
||||
if (adapter.processSubagentToolCall) {
|
||||
adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId);
|
||||
adapter.processSubagentToolCall(toolCallToEmit, agentToolCallId);
|
||||
emittedToolUseIds.add(toolCall.callId);
|
||||
}
|
||||
}
|
||||
|
|
@ -461,7 +461,7 @@ export function createTaskToolProgressHandler(
|
|||
* @param toolCall - The tool call information
|
||||
*/
|
||||
const emitToolResultIfNeeded = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
): void => {
|
||||
if (emittedToolResultIds.has(toolCall.callId)) {
|
||||
return;
|
||||
|
|
@ -482,7 +482,7 @@ export function createTaskToolProgressHandler(
|
|||
'emitToolResult' in adapter &&
|
||||
typeof adapter.emitToolResult === 'function'
|
||||
) {
|
||||
adapter.emitToolResult(request, response, taskToolCallId);
|
||||
adapter.emitToolResult(request, response, agentToolCallId);
|
||||
} else {
|
||||
adapter.emitToolResult(request, response);
|
||||
}
|
||||
|
|
@ -495,8 +495,8 @@ export function createTaskToolProgressHandler(
|
|||
* @param previousCall - The previous state of the tool call (if any)
|
||||
*/
|
||||
const processToolCall = (
|
||||
toolCall: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
previousCall?: NonNullable<TaskResultDisplay['toolCalls']>[number],
|
||||
toolCall: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
previousCall?: NonNullable<AgentResultDisplay['toolCalls']>[number],
|
||||
): void => {
|
||||
const isCompleted =
|
||||
toolCall.status === 'success' || toolCall.status === 'failed';
|
||||
|
|
@ -531,14 +531,14 @@ export function createTaskToolProgressHandler(
|
|||
callId: string,
|
||||
outputChunk: ToolResultDisplay,
|
||||
) => {
|
||||
// Only process TaskResultDisplay (Task tool updates)
|
||||
// Only process AgentResultDisplay (Task tool updates)
|
||||
if (
|
||||
typeof outputChunk === 'object' &&
|
||||
outputChunk !== null &&
|
||||
'type' in outputChunk &&
|
||||
outputChunk.type === 'task_execution'
|
||||
) {
|
||||
const taskDisplay = outputChunk as TaskResultDisplay;
|
||||
const taskDisplay = outputChunk as AgentResultDisplay;
|
||||
const previous = previousTaskStates.get(callId);
|
||||
|
||||
// Only process if adapter supports subagent APIs
|
||||
|
|
@ -585,7 +585,7 @@ export function createTaskToolProgressHandler(
|
|||
? 'Task was cancelled'
|
||||
: 'Task execution failed');
|
||||
// Use subagent adapter's emitSubagentErrorResult method
|
||||
adapter.emitSubagentErrorResult(errorMessage, 0, taskToolCallId);
|
||||
adapter.emitSubagentErrorResult(errorMessage, 0, agentToolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -601,7 +601,7 @@ export function createTaskToolProgressHandler(
|
|||
// Emit the user message with the correct parent_tool_use_id
|
||||
adapter.emitUserMessage(
|
||||
[{ text: taskDisplay.taskPrompt }],
|
||||
taskToolCallId,
|
||||
agentToolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -610,7 +610,7 @@ export function createTaskToolProgressHandler(
|
|||
}
|
||||
};
|
||||
|
||||
// No longer need to attach adapter to handler - task.ts uses TaskResultDisplay.message instead
|
||||
// No longer need to attach adapter to handler - task.ts uses AgentResultDisplay.message instead
|
||||
|
||||
return {
|
||||
handler: outputUpdateHandler,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue