Merge remote-tracking branch 'origin/main' into refactor/subagent-model-selection

This commit is contained in:
tanzhenxin 2026-03-27 11:56:29 +08:00
commit ad20049a4e
99 changed files with 10967 additions and 1088 deletions

View file

@ -623,7 +623,7 @@ export class Session implements SessionContext {
if (confirmationDetails.type === 'edit') {
content.push({
type: 'diff',
path: confirmationDetails.fileName,
path: confirmationDetails.filePath || confirmationDetails.fileName,
oldText: confirmationDetails.originalContent,
newText: confirmationDetails.newContent,
});

View file

@ -531,6 +531,86 @@ describe('SubAgentTracker', () => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
});
});
it('should use filePath over fileName for diff content path', async () => {
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'edit_file',
callId: 'call-path-test',
description: 'Editing file',
confirmationDetails: createEditConfirmation({
fileName: 'test.ts',
filePath: '/workspace/src/test.ts',
originalContent: 'old content',
newContent: 'new content',
}),
respond: respondSpy,
});
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
});
expect(requestPermissionSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
content: [
{
type: 'diff',
path: '/workspace/src/test.ts',
oldText: 'old content',
newText: 'new content',
},
],
}),
}),
);
});
it('should fall back to fileName when filePath is not available', async () => {
tracker.setup(eventEmitter, abortController.signal);
const respondSpy = vi.fn().mockResolvedValue(undefined);
const event = createApprovalEvent({
name: 'edit_file',
callId: 'call-fallback-test',
description: 'Editing file',
confirmationDetails: {
type: 'edit' as const,
title: 'Edit file',
fileName: 'fallback.ts',
fileDiff: '',
originalContent: 'old',
newContent: 'new',
} as Omit<ToolEditConfirmationDetails, 'onConfirm'>,
respond: respondSpy,
});
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
});
expect(requestPermissionSpy).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
content: [
{
type: 'diff',
path: 'fallback.ts',
oldText: 'old',
newText: 'new',
},
],
}),
}),
);
});
});
describe('permission options', () => {

View file

@ -226,12 +226,13 @@ export class SubAgentTracker {
const editDetails = event.confirmationDetails as unknown as {
type: 'edit';
fileName: string;
filePath: string;
originalContent: string | null;
newContent: string;
};
content.push({
type: 'diff',
path: editDetails.fileName,
path: editDetails.filePath || editDetails.fileName,
oldText: editDetails.originalContent ?? '',
newText: editDetails.newContent,
});

View file

@ -1,25 +1,25 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { enableCommand } from './hooks/enable.js';
import { disableCommand } from './hooks/disable.js';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
const debugLogger = createDebugLogger('HOOKS_UI');
export const hooksCommand: CommandModule = {
command: 'hooks <command>',
command: 'hooks',
aliases: ['hook'],
describe: 'Manage Qwen Code hooks.',
builder: (yargs) =>
yargs
.command(enableCommand)
.command(disableCommand)
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),
describe: 'Manage Qwen Code hooks (use /hooks in interactive mode).',
builder: (yargs) => yargs.version(false).help(false),
handler: () => {
// This handler is not called when a subcommand is provided.
// Yargs will show the help menu.
// In CLI mode, this command is not interactive.
// Users should use /hooks in interactive mode for the full UI experience.
debugLogger.debug(
'Use /hooks in interactive mode to manage hooks with the UI.',
);
process.exit(0);
},
};

View file

@ -1,75 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../config/settings.js';
const debugLogger = createDebugLogger('HOOKS_DISABLE');
interface DisableArgs {
hookName: string;
}
/**
* Disable a hook by adding it to the disabled list
*/
export async function handleDisableHook(hookName: string): Promise<void> {
const workingDir = process.cwd();
const settings = loadSettings(workingDir);
try {
// Get current hooks settings
const mergedSettings = settings.merged as
| Record<string, unknown>
| undefined;
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
string,
unknown
>;
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
// Check if hook is already disabled
if (disabledHooks.includes(hookName)) {
debugLogger.info(`Hook "${hookName}" is already disabled.`);
return;
}
// Add hook to disabled list
const newDisabledHooks = [...disabledHooks, hookName];
const newHooksSettings = {
...hooksSettings,
disabled: newDisabledHooks,
};
// Save updated settings
settings.setValue(
SettingScope.Workspace,
'hooks' as keyof typeof settings.merged,
newHooksSettings as never,
);
debugLogger.info(`✓ Hook "${hookName}" has been disabled.`);
} catch (error) {
debugLogger.error(`Error disabling hook: ${getErrorMessage(error)}`);
}
}
export const disableCommand: CommandModule = {
command: 'disable <hook-name>',
describe: 'Disable an active hook',
builder: (yargs) =>
yargs.positional('hook-name', {
describe: 'Name of the hook to disable',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
const args = argv as unknown as DisableArgs;
await handleDisableHook(args.hookName);
process.exit(0);
},
};

View file

@ -1,75 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { createDebugLogger, getErrorMessage } from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../config/settings.js';
const debugLogger = createDebugLogger('HOOKS_ENABLE');
interface EnableArgs {
hookName: string;
}
/**
* Enable a hook by removing it from the disabled list
*/
export async function handleEnableHook(hookName: string): Promise<void> {
const workingDir = process.cwd();
const settings = loadSettings(workingDir);
try {
// Get current hooks settings
const mergedSettings = settings.merged as
| Record<string, unknown>
| undefined;
const hooksSettings = (mergedSettings?.['hooks'] || {}) as Record<
string,
unknown
>;
const disabledHooks = (hooksSettings['disabled'] || []) as string[];
// Check if hook is in disabled list
if (!disabledHooks.includes(hookName)) {
debugLogger.info(`Hook "${hookName}" is not disabled.`);
return;
}
// Remove hook from disabled list
const newDisabledHooks = disabledHooks.filter((h) => h !== hookName);
const newHooksSettings = {
...hooksSettings,
disabled: newDisabledHooks,
};
// Save updated settings
settings.setValue(
SettingScope.Workspace,
'hooks' as keyof typeof settings.merged,
newHooksSettings as never,
);
debugLogger.info(`✓ Hook "${hookName}" has been enabled.`);
} catch (error) {
debugLogger.error(`Error enabling hook: ${getErrorMessage(error)}`);
}
}
export const enableCommand: CommandModule = {
command: 'enable <hook-name>',
describe: 'Enable a disabled hook',
builder: (yargs) =>
yargs.positional('hook-name', {
describe: 'Name of the hook to enable',
type: 'string',
demandOption: true,
}),
handler: async (argv) => {
const args = argv as unknown as EnableArgs;
await handleEnableHook(args.hookName);
process.exit(0);
},
};

View file

@ -107,7 +107,7 @@ export interface SettingsSchema {
/**
* Common items schema for hook definitions.
* Used by both UserPromptSubmit and Stop hooks.
* Used by all hook event types in the hooks configuration.
*/
const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
type: 'object',
@ -1481,6 +1481,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute when notifications are sent.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PreToolUse: {
type: 'array',
@ -1491,6 +1492,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute before tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PostToolUse: {
type: 'array',
@ -1501,6 +1503,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute after successful tool execution.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PostToolUseFailure: {
type: 'array',
@ -1511,6 +1514,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute when tool execution fails. ',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
SessionStart: {
type: 'array',
@ -1521,6 +1525,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute when a new session starts or resumes.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
SessionEnd: {
type: 'array',
@ -1531,6 +1536,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute when a session ends.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PreCompact: {
type: 'array',
@ -1541,6 +1547,7 @@ const SETTINGS_SCHEMA = {
description: 'Hooks that execute before conversation compaction.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
SubagentStart: {
type: 'array',
@ -1552,6 +1559,7 @@ const SETTINGS_SCHEMA = {
'Hooks that execute when a subagent (Task tool call) is started.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
SubagentStop: {
type: 'array',
@ -1563,6 +1571,7 @@ const SETTINGS_SCHEMA = {
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
PermissionRequest: {
type: 'array',
@ -1574,6 +1583,7 @@ const SETTINGS_SCHEMA = {
'Hooks that execute when a permission dialog is displayed.',
showInDialog: false,
mergeStrategy: MergeStrategy.CONCAT,
items: HOOK_DEFINITION_ITEMS,
},
},
},

View file

@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AuthType } from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { initializeApp } from './initializer.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n } from '../i18n/index.js';
vi.mock('./auth.js', () => ({
performInitialAuth: vi.fn(),
}));
vi.mock('./theme.js', () => ({
validateTheme: vi.fn(),
}));
vi.mock('../i18n/index.js', () => ({
initializeI18n: vi.fn(),
}));
describe('initializeApp', () => {
beforeEach(() => {
vi.clearAllMocks();
delete process.env['QWEN_CODE_LANG'];
vi.mocked(initializeI18n).mockResolvedValue(undefined);
vi.mocked(validateTheme).mockReturnValue(null);
});
function createMockConfig(
options: {
authType?: AuthType;
wasAuthTypeExplicitlyProvided?: boolean;
geminiMdFileCount?: number;
ideMode?: boolean;
} = {},
): Config {
const {
authType = AuthType.USE_OPENAI,
wasAuthTypeExplicitlyProvided = true,
geminiMdFileCount = 0,
ideMode = false,
} = options;
return {
getModelsConfig: vi.fn().mockReturnValue({
getCurrentAuthType: vi.fn().mockReturnValue(authType),
wasAuthTypeExplicitlyProvided: vi
.fn()
.mockReturnValue(wasAuthTypeExplicitlyProvided),
}),
getIdeMode: vi.fn().mockReturnValue(ideMode),
getGeminiMdFileCount: vi.fn().mockReturnValue(geminiMdFileCount),
} as unknown as Config;
}
function createMockSettings(): LoadedSettings {
return {
merged: {
general: {
language: 'en',
},
},
setValue: vi.fn(),
} as unknown as LoadedSettings;
}
it('should not clear selected auth type when initial auth fails', async () => {
vi.mocked(performInitialAuth).mockResolvedValue(
'Failed to login. Message: missing OLLAMA_API_KEY',
);
const config = createMockConfig({
authType: AuthType.USE_OPENAI,
wasAuthTypeExplicitlyProvided: true,
});
const settings = createMockSettings();
const result = await initializeApp(config, settings);
expect(result.authError).toBe(
'Failed to login. Message: missing OLLAMA_API_KEY',
);
expect(result.shouldOpenAuthDialog).toBe(true);
expect(settings.setValue).not.toHaveBeenCalled();
});
it('should not open auth dialog when auth is explicit and succeeds', async () => {
vi.mocked(performInitialAuth).mockResolvedValue(null);
const config = createMockConfig({
authType: AuthType.USE_OPENAI,
wasAuthTypeExplicitlyProvided: true,
});
const settings = createMockSettings();
const result = await initializeApp(config, settings);
expect(result.authError).toBeNull();
expect(result.shouldOpenAuthDialog).toBe(false);
});
});

View file

@ -11,7 +11,7 @@ import {
logIdeConnection,
type Config,
} from '@qwen-code/qwen-code-core';
import { type LoadedSettings, SettingScope } from '../config/settings.js';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
import { validateTheme } from './theme.js';
import { initializeI18n, type SupportedLanguage } from '../i18n/index.js';
@ -46,14 +46,6 @@ export async function initializeApp(
const authType = config.getModelsConfig().getCurrentAuthType();
const authError = await performInitialAuth(config, authType);
// Fallback to user select when initial authentication fails
if (authError) {
settings.setValue(
SettingScope.User,
'security.auth.selectedType',
undefined,
);
}
const themeError = validateTheme(settings);
const shouldOpenAuthDialog =

View file

@ -594,6 +594,121 @@ export default {
'List all configured hooks': 'Alle konfigurierten Hooks auflisten',
'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren',
'Disable an active hook': 'Einen aktiven Hook deaktivieren',
// Hooks - Dialog
Hooks: 'Hooks',
'Loading hooks...': 'Hooks werden geladen...',
'Error loading hooks:': 'Fehler beim Laden der Hooks:',
'Press Escape to close': 'Escape zum Schließen drücken',
'No hook selected': 'Kein Hook ausgewählt',
// Hooks - List Step
'No hook events found.': 'Keine Hook-Ereignisse gefunden.',
'{{count}} hook configured': '{{count}} Hook konfiguriert',
'{{count}} hooks configured': '{{count}} Hooks konfiguriert',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'Dieses Menü ist schreibgeschützt. Um Hooks hinzuzufügen oder zu ändern, bearbeiten Sie settings.json direkt oder fragen Sie Qwen Code.',
'Enter to select · Esc to cancel': 'Enter zum Auswählen · Esc zum Abbrechen',
// Hooks - Detail Step
'Exit codes:': 'Exit-Codes:',
'Configured hooks:': 'Konfigurierte Hooks:',
'No hooks configured for this event.':
'Für dieses Ereignis sind keine Hooks konfiguriert.',
'To add hooks, edit settings.json directly or ask Qwen.':
'Um Hooks hinzuzufügen, bearbeiten Sie settings.json direkt oder fragen Sie Qwen.',
'Enter to select · Esc to go back': 'Enter zum Auswählen · Esc zum Zurück',
// Hooks - Config Detail Step
'Hook details': 'Hook-Details',
'Event:': 'Ereignis:',
'Extension:': 'Erweiterung:',
'Desc:': 'Beschreibung:',
'No hook config selected': 'Keine Hook-Konfiguration ausgewählt',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Um diesen Hook zu ändern oder zu entfernen, bearbeiten Sie settings.json direkt oder fragen Sie Qwen.',
// Hooks - Source
Project: 'Projekt',
User: 'Benutzer',
System: 'System',
Extension: 'Erweiterung',
'Local Settings': 'Lokale Einstellungen',
'User Settings': 'Benutzereinstellungen',
'System Settings': 'Systemeinstellungen',
Extensions: 'Erweiterungen',
// Hooks - Status
'✓ Enabled': '✓ Aktiviert',
'✗ Disabled': '✗ Deaktiviert',
// Hooks - Event Descriptions (short)
'Before tool execution': 'Vor der Tool-Ausführung',
'After tool execution': 'Nach der Tool-Ausführung',
'After tool execution fails': 'Wenn die Tool-Ausführung fehlschlägt',
'When notifications are sent': 'Wenn Benachrichtigungen gesendet werden',
'When the user submits a prompt': 'Wenn der Benutzer einen Prompt absendet',
'When a new session is started': 'Wenn eine neue Sitzung gestartet wird',
'Right before Qwen Code concludes its response':
'Direkt bevor Qwen Code seine Antwort abschließt',
'When a subagent (Agent tool call) is started':
'Wenn ein Subagent (Agent-Tool-Aufruf) gestartet wird',
'Right before a subagent concludes its response':
'Direkt bevor ein Subagent seine Antwort abschließt',
'Before conversation compaction': 'Vor der Gesprächskomprimierung',
'When a session is ending': 'Wenn eine Sitzung endet',
'When a permission dialog is displayed':
'Wenn ein Berechtigungsdialog angezeigt wird',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'Die Eingabe an den Befehl ist JSON der Tool-Aufruf-Argumente.',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'Die Eingabe an den Befehl ist JSON mit den Feldern "inputs" (Tool-Aufruf-Argumente) und "response" (Tool-Aufruf-Antwort).',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input, tool_use_id, error, error_type, is_interrupt und is_timeout.',
'Input to command is JSON with notification message and type.':
'Die Eingabe an den Befehl ist JSON mit Benachrichtigungsnachricht und -typ.',
'Input to command is JSON with original user prompt text.':
'Die Eingabe an den Befehl ist JSON mit dem ursprünglichen Benutzer-Prompt-Text.',
'Input to command is JSON with session start source.':
'Die Eingabe an den Befehl ist JSON mit der Sitzungsstart-Quelle.',
'Input to command is JSON with session end reason.':
'Die Eingabe an den Befehl ist JSON mit dem Sitzungsende-Grund.',
'Input to command is JSON with agent_id and agent_type.':
'Die Eingabe an den Befehl ist JSON mit agent_id und agent_type.',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'Die Eingabe an den Befehl ist JSON mit agent_id, agent_type und agent_transcript_path.',
'Input to command is JSON with compaction details.':
'Die Eingabe an den Befehl ist JSON mit Komprimierungsdetails.',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'Die Eingabe an den Befehl ist JSON mit tool_name, tool_input und tool_use_id. Ausgabe ist JSON mit hookSpecificOutput, das die Entscheidung zum Zulassen oder Ablehnen enthält.',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr nicht angezeigt',
'show stderr to model and continue conversation':
'stderr dem Modell anzeigen und Konversation fortsetzen',
'show stderr to user only': 'stderr nur dem Benutzer anzeigen',
'stdout shown in transcript mode (ctrl+o)':
'stdout im Transkriptmodus angezeigt (ctrl+o)',
'show stderr to model immediately': 'stderr sofort dem Modell anzeigen',
'show stderr to user only but continue with tool call':
'stderr nur dem Benutzer anzeigen, aber mit Tool-Aufruf fortfahren',
'block processing, erase original prompt, and show stderr to user only':
'Verarbeitung blockieren, ursprünglichen Prompt löschen und stderr nur dem Benutzer anzeigen',
'stdout shown to Qwen': 'stdout dem Qwen anzeigen',
'show stderr to user only (blocking errors ignored)':
'stderr nur dem Benutzer anzeigen (Blockierungsfehler ignoriert)',
'command completes successfully': 'Befehl erfolgreich abgeschlossen',
'stdout shown to subagent': 'stdout dem Subagenten anzeigen',
'show stderr to subagent and continue having it run':
'stderr dem Subagenten anzeigen und ihn weiterlaufen lassen',
'stdout appended as custom compact instructions':
'stdout als benutzerdefinierte Komprimierungsanweisungen angehängt',
'block compaction': 'Komprimierung blockieren',
'show stderr to user only but continue with compaction':
'stderr nur dem Benutzer anzeigen, aber mit Komprimierung fortfahren',
'use hook decision if provided':
'Hook-Entscheidung verwenden, falls bereitgestellt',
// Hooks - Messages
'Config not loaded.': 'Konfiguration nicht geladen.',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'Hooks sind nicht aktiviert. Aktivieren Sie Hooks in den Einstellungen, um diese Funktion zu nutzen.',
'No hooks configured. Add hooks in your settings.json file.':
'Keine Hooks konfiguriert. Fügen Sie Hooks in Ihrer settings.json-Datei hinzu.',
'Configured Hooks ({{count}} total)':
'Konfigurierte Hooks ({{count}} insgesamt)',
// ============================================================================
// Commands - Session Export
@ -708,7 +823,6 @@ export default {
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'Arbeitsbereich-Genehmigungsmodus existiert und hat Vorrang. Benutzerebene-Änderung hat keine Wirkung.',
'Apply To': 'Anwenden auf',
'User Settings': 'Benutzereinstellungen',
'Workspace Settings': 'Arbeitsbereich-Einstellungen',
// ============================================================================
@ -763,7 +877,6 @@ export default {
'List configured MCP servers and tools':
'Konfigurierte MCP-Server und Werkzeuge auflisten',
'Restarts MCP servers.': 'MCP-Server neu starten.',
'Config not loaded.': 'Konfiguration nicht geladen.',
'Could not retrieve tool registry.':
'Werkzeugregister konnte nicht abgerufen werden.',
'No MCP servers configured with OAuth authentication.':
@ -972,7 +1085,6 @@ export default {
'No server selected': 'Kein Server ausgewählt',
'(disabled)': '(deaktiviert)',
'Error:': 'Fehler:',
Extension: 'Erweiterung',
tool: 'Werkzeug',
tools: 'Werkzeuge',
connected: 'verbunden',
@ -1047,7 +1159,11 @@ export default {
"Ausführung erlauben von: '{{command}}'?",
'Yes, allow always ...': 'Ja, immer erlauben ...',
'Always allow in this project': 'In diesem Projekt immer erlauben',
'Always allow {{action}} in this project':
'{{action}} in diesem Projekt immer erlauben',
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
'Always allow {{action}} for this user':
'{{action}} 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)',

View file

@ -668,6 +668,118 @@ export default {
'List all configured hooks': 'List all configured hooks',
'Enable a disabled hook': 'Enable a disabled hook',
'Disable an active hook': 'Disable an active hook',
// Hooks - Dialog
Hooks: 'Hooks',
'Loading hooks...': 'Loading hooks...',
'Error loading hooks:': 'Error loading hooks:',
'Press Escape to close': 'Press Escape to close',
'No hook selected': 'No hook selected',
// Hooks - List Step
'No hook events found.': 'No hook events found.',
'{{count}} hook configured': '{{count}} hook configured',
'{{count}} hooks configured': '{{count}} hooks configured',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
'Enter to select · Esc to cancel': 'Enter to select · Esc to cancel',
// Hooks - Detail Step
'Exit codes:': 'Exit codes:',
'Configured hooks:': 'Configured hooks:',
'No hooks configured for this event.': 'No hooks configured for this event.',
'To add hooks, edit settings.json directly or ask Qwen.':
'To add hooks, edit settings.json directly or ask Qwen.',
'Enter to select · Esc to go back': 'Enter to select · Esc to go back',
// Hooks - Config Detail Step
'Hook details': 'Hook details',
'Event:': 'Event:',
'Extension:': 'Extension:',
'Desc:': 'Desc:',
'No hook config selected': 'No hook config selected',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
// Hooks - Source
Project: 'Project',
User: 'User',
System: 'System',
Extension: 'Extension',
'Local Settings': 'Local Settings',
'User Settings': 'User Settings',
'System Settings': 'System Settings',
Extensions: 'Extensions',
// Hooks - Status
'✓ Enabled': '✓ Enabled',
'✗ Disabled': '✗ Disabled',
// Hooks - Event Descriptions (short)
'Before tool execution': 'Before tool execution',
'After tool execution': 'After tool execution',
'After tool execution fails': 'After tool execution fails',
'When notifications are sent': 'When notifications are sent',
'When the user submits a prompt': 'When the user submits a prompt',
'When a new session is started': 'When a new session is started',
'Right before Qwen Code concludes its response':
'Right before Qwen Code concludes its response',
'When a subagent (Agent tool call) is started':
'When a subagent (Agent tool call) is started',
'Right before a subagent concludes its response':
'Right before a subagent concludes its response',
'Before conversation compaction': 'Before conversation compaction',
'When a session is ending': 'When a session is ending',
'When a permission dialog is displayed':
'When a permission dialog is displayed',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'Input to command is JSON of tool call arguments.',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
'Input to command is JSON with notification message and type.':
'Input to command is JSON with notification message and type.',
'Input to command is JSON with original user prompt text.':
'Input to command is JSON with original user prompt text.',
'Input to command is JSON with session start source.':
'Input to command is JSON with session start source.',
'Input to command is JSON with session end reason.':
'Input to command is JSON with session end reason.',
'Input to command is JSON with agent_id and agent_type.':
'Input to command is JSON with agent_id and agent_type.',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.',
'Input to command is JSON with compaction details.':
'Input to command is JSON with compaction details.',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr not shown',
'show stderr to model and continue conversation':
'show stderr to model and continue conversation',
'show stderr to user only': 'show stderr to user only',
'stdout shown in transcript mode (ctrl+o)':
'stdout shown in transcript mode (ctrl+o)',
'show stderr to model immediately': 'show stderr to model immediately',
'show stderr to user only but continue with tool call':
'show stderr to user only but continue with tool call',
'block processing, erase original prompt, and show stderr to user only':
'block processing, erase original prompt, and show stderr to user only',
'stdout shown to Qwen': 'stdout shown to Qwen',
'show stderr to user only (blocking errors ignored)':
'show stderr to user only (blocking errors ignored)',
'command completes successfully': 'command completes successfully',
'stdout shown to subagent': 'stdout shown to subagent',
'show stderr to subagent and continue having it run':
'show stderr to subagent and continue having it run',
'stdout appended as custom compact instructions':
'stdout appended as custom compact instructions',
'block compaction': 'block compaction',
'show stderr to user only but continue with compaction':
'show stderr to user only but continue with compaction',
'use hook decision if provided': 'use hook decision if provided',
// Hooks - Messages
'Config not loaded.': 'Config not loaded.',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'Hooks are not enabled. Enable hooks in settings to use this feature.',
'No hooks configured. Add hooks in your settings.json file.':
'No hooks configured. Add hooks in your settings.json file.',
'Configured Hooks ({{count}} total)': 'Configured Hooks ({{count}} total)',
// ============================================================================
// Commands - Session Export
@ -775,7 +887,6 @@ export default {
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'Workspace approval mode exists and takes priority. User-level change will have no effect.',
'Apply To': 'Apply To',
'User Settings': 'User Settings',
'Workspace Settings': 'Workspace Settings',
// ============================================================================
@ -829,7 +940,6 @@ export default {
'List configured MCP servers and tools',
'Restarts MCP servers.': 'Restarts MCP servers.',
'Open MCP management dialog': 'Open MCP management dialog',
'Config not loaded.': 'Config not loaded.',
'Could not retrieve tool registry.': 'Could not retrieve tool registry.',
'No MCP servers configured with OAuth authentication.':
'No MCP servers configured with OAuth authentication.',
@ -895,7 +1005,6 @@ export default {
prompts: 'prompts',
'(disabled)': '(disabled)',
'Error:': 'Error:',
Extension: 'Extension',
tool: 'tool',
tools: 'tools',
connected: 'connected',
@ -1103,7 +1212,11 @@ export default {
"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 {{action}} in this project':
'Always allow {{action}} in this project',
'Always allow for this user': 'Always allow for this user',
'Always allow {{action}} for this user':
'Always allow {{action}} 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)',

View file

@ -380,6 +380,118 @@ export default {
'List all configured hooks': '設定済みのフックをすべて表示する',
'Enable a disabled hook': '無効なフックを有効にする',
'Disable an active hook': '有効なフックを無効にする',
// Hooks - Dialog
Hooks: 'フック',
'Loading hooks...': 'フックを読み込んでいます...',
'Error loading hooks:': 'フックの読み込みエラー:',
'Press Escape to close': 'Escape キーで閉じる',
'No hook selected': 'フックが選択されていません',
// Hooks - List Step
'No hook events found.': 'フックイベントが見つかりません。',
'{{count}} hook configured': '{{count}} 件のフックが設定されています',
'{{count}} hooks configured': '{{count}} 件のフックが設定されています',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'このメニューは読み取り専用です。フックを追加または変更するには、settings.json を直接編集するか、Qwen Code に尋ねてください。',
'Enter to select · Esc to cancel': 'Enter で選択 · Esc でキャンセル',
// Hooks - Detail Step
'Exit codes:': '終了コード:',
'Configured hooks:': '設定済みのフック:',
'No hooks configured for this event.':
'このイベントにはフックが設定されていません。',
'To add hooks, edit settings.json directly or ask Qwen.':
'フックを追加するには、settings.json を直接編集するか、Qwen に尋ねてください。',
'Enter to select · Esc to go back': 'Enter で選択 · Esc で戻る',
// Hooks - Config Detail Step
'Hook details': 'フック詳細',
'Event:': 'イベント:',
'Extension:': '拡張機能:',
'Desc:': '説明:',
'No hook config selected': 'フック設定が選択されていません',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'このフックを変更または削除するには、settings.json を直接編集するか、Qwen に尋ねてください。',
// Hooks - Source
Project: 'プロジェクト',
User: 'ユーザー',
System: 'システム',
Extension: '拡張機能',
'Local Settings': 'ローカル設定',
'User Settings': 'ユーザー設定',
'System Settings': 'システム設定',
Extensions: '拡張機能',
// Hooks - Status
'✓ Enabled': '✓ 有効',
'✗ Disabled': '✗ 無効',
// Hooks - Event Descriptions (short)
'Before tool execution': 'ツール実行前',
'After tool execution': 'ツール実行後',
'After tool execution fails': 'ツール実行失敗時',
'When notifications are sent': '通知送信時',
'When the user submits a prompt': 'ユーザーがプロンプトを送信した時',
'When a new session is started': '新しいセッションが開始された時',
'Right before Qwen Code concludes its response':
'Qwen Code が応答を終了する直前',
'When a subagent (Agent tool call) is started':
'サブエージェントAgent ツール呼び出し)が開始された時',
'Right before a subagent concludes its response':
'サブエージェントが応答を終了する直前',
'Before conversation compaction': '会話圧縮前',
'When a session is ending': 'セッション終了時',
'When a permission dialog is displayed': '権限ダイアログ表示時',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'コマンドへの入力はツール呼び出し引数の JSON です。',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'コマンドへの入力は "inputs"(ツール呼び出し引数)と "response"(ツール呼び出し応答)フィールドを持つ JSON です。',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'コマンドへの入力は tool_name、tool_input、tool_use_id、error、error_type、is_interrupt、is_timeout を持つ JSON です。',
'Input to command is JSON with notification message and type.':
'コマンドへの入力は通知メッセージとタイプを持つ JSON です。',
'Input to command is JSON with original user prompt text.':
'コマンドへの入力は元のユーザープロンプトテキストを持つ JSON です。',
'Input to command is JSON with session start source.':
'コマンドへの入力はセッション開始ソースを持つ JSON です。',
'Input to command is JSON with session end reason.':
'コマンドへの入力はセッション終了理由を持つ JSON です。',
'Input to command is JSON with agent_id and agent_type.':
'コマンドへの入力は agent_id と agent_type を持つ JSON です。',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'コマンドへの入力は agent_id、agent_type、agent_transcript_path を持つ JSON です。',
'Input to command is JSON with compaction details.':
'コマンドへの入力は圧縮詳細を持つ JSON です。',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'コマンドへの入力は tool_name、tool_input、tool_use_id を持つ JSON です。許可または拒否の決定を含む hookSpecificOutput を持つ JSON を出力します。',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr は表示されません',
'show stderr to model and continue conversation':
'stderr をモデルに表示し、会話を続ける',
'show stderr to user only': 'stderr をユーザーのみに表示',
'stdout shown in transcript mode (ctrl+o)':
'stdout はトランスクリプトモードで表示 (ctrl+o)',
'show stderr to model immediately': 'stderr をモデルに即座に表示',
'show stderr to user only but continue with tool call':
'stderr をユーザーのみに表示し、ツール呼び出しを続ける',
'block processing, erase original prompt, and show stderr to user only':
'処理をブロックし、元のプロンプトを消去し、stderr をユーザーのみに表示',
'stdout shown to Qwen': 'stdout をモデルに表示',
'show stderr to user only (blocking errors ignored)':
'stderr をユーザーのみに表示(ブロッキングエラーは無視)',
'command completes successfully': 'コマンドが正常に完了',
'stdout shown to subagent': 'stdout をサブエージェントに表示',
'show stderr to subagent and continue having it run':
'stderr をサブエージェントに表示し、実行を続ける',
'stdout appended as custom compact instructions':
'stdout をカスタム圧縮指示として追加',
'block compaction': '圧縮をブロック',
'show stderr to user only but continue with compaction':
'stderr をユーザーのみに表示し、圧縮を続ける',
'use hook decision if provided': '提供されている場合はフックの決定を使用',
// Hooks - Messages
'Config not loaded.': '設定が読み込まれていません。',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'フックが有効になっていません。この機能を使用するには設定でフックを有効にしてください。',
'No hooks configured. Add hooks in your settings.json file.':
'フックが設定されていません。settings.json ファイルにフックを追加してください。',
'Configured Hooks ({{count}} total)': '設定済みのフック(合計 {{count}} 件)',
// ============================================================================
// Commands - Session Export
@ -480,7 +592,6 @@ export default {
'(Use Enter to select, Tab to change focus)':
'(Enter で選択、Tab でフォーカス変更)',
'Apply To': '適用先',
'User Settings': 'ユーザー設定',
'Workspace Settings': 'ワークスペース設定',
// Memory
'Commands for interacting with memory.': 'メモリ操作のコマンド',
@ -527,7 +638,6 @@ export default {
'設定済みのMCPサーバーとツールを一覧表示',
'No MCP servers configured.': 'MCPサーバーが設定されていません',
'Restarts MCP servers.': 'MCPサーバーを再起動します',
'Config not loaded.': '設定が読み込まれていません',
'Could not retrieve tool registry.': 'ツールレジストリを取得できませんでした',
'No MCP servers configured with OAuth authentication.':
'OAuth認証が設定されたMCPサーバーはありません',
@ -712,7 +822,6 @@ export default {
'No server selected': 'サーバーが選択されていません',
'(disabled)': '(無効)',
'Error:': 'エラー:',
Extension: '拡張機能',
tool: 'ツール',
tools: 'ツール',
connected: '接続済み',
@ -786,7 +895,10 @@ export default {
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
'Yes, allow always ...': 'はい、常に許可...',
'Always allow in this project': 'このプロジェクトで常に許可',
'Always allow {{action}} in this project':
'このプロジェクトで{{action}}を常に許可',
'Always allow for this user': 'このユーザーに常に許可',
'Always allow {{action}} for this user': 'このユーザーに{{action}}を常に許可',
'Yes, and auto-accept edits': 'はい、編集を自動承認',
'Yes, and manually approve edits': 'はい、編集を手動承認',
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',

View file

@ -599,6 +599,120 @@ export default {
'List all configured hooks': 'Listar todos os hooks configurados',
'Enable a disabled hook': 'Ativar um hook desativado',
'Disable an active hook': 'Desativar um hook ativo',
// Hooks - Dialog
Hooks: 'Hooks',
'Loading hooks...': 'Carregando hooks...',
'Error loading hooks:': 'Erro ao carregar hooks:',
'Press Escape to close': 'Pressione Escape para fechar',
'No hook selected': 'Nenhum hook selecionado',
// Hooks - List Step
'No hook events found.': 'Nenhum evento de hook encontrado.',
'{{count}} hook configured': '{{count}} hook configurado',
'{{count}} hooks configured': '{{count}} hooks configurados',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'Este menu é somente leitura. Para adicionar ou modificar hooks, edite settings.json diretamente ou pergunte ao Qwen Code.',
'Enter to select · Esc to cancel':
'Enter para selecionar · Esc para cancelar',
// Hooks - Detail Step
'Exit codes:': 'Códigos de saída:',
'Configured hooks:': 'Hooks configurados:',
'No hooks configured for this event.':
'Nenhum hook configurado para este evento.',
'To add hooks, edit settings.json directly or ask Qwen.':
'Para adicionar hooks, edite settings.json diretamente ou pergunte ao Qwen.',
'Enter to select · Esc to go back': 'Enter para selecionar · Esc para voltar',
// Hooks - Config Detail Step
'Hook details': 'Detalhes do Hook',
'Event:': 'Evento:',
'Extension:': 'Extensão:',
'Desc:': 'Descrição:',
'No hook config selected': 'Nenhuma configuração de hook selecionada',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Para modificar ou remover este hook, edite settings.json diretamente ou pergunte ao Qwen.',
// Hooks - Source
Project: 'Projeto',
User: 'Usuário',
System: 'Sistema',
Extension: 'Extensão',
'Local Settings': 'Configurações Locais',
'User Settings': 'Configurações do Usuário',
'System Settings': 'Configurações do Sistema',
Extensions: 'Extensões',
// Hooks - Status
'✓ Enabled': '✓ Ativado',
'✗ Disabled': '✗ Desativado',
// Hooks - Event Descriptions (short)
'Before tool execution': 'Antes da execução da ferramenta',
'After tool execution': 'Após a execução da ferramenta',
'After tool execution fails': 'Após a falha da execução da ferramenta',
'When notifications are sent': 'Quando notificações são enviadas',
'When the user submits a prompt': 'Quando o usuário envia um prompt',
'When a new session is started': 'Quando uma nova sessão é iniciada',
'Right before Qwen Code concludes its response':
'Logo antes do Qwen Code concluir sua resposta',
'When a subagent (Agent tool call) is started':
'Quando um subagente (chamada de ferramenta Agent) é iniciado',
'Right before a subagent concludes its response':
'Logo antes de um subagente concluir sua resposta',
'Before conversation compaction': 'Antes da compactação da conversa',
'When a session is ending': 'Quando uma sessão está terminando',
'When a permission dialog is displayed':
'Quando um diálogo de permissão é exibido',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'A entrada para o comando é JSON dos argumentos da chamada da ferramenta.',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'A entrada para o comando é JSON com campos "inputs" (argumentos da chamada da ferramenta) e "response" (resposta da chamada da ferramenta).',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'A entrada para o comando é JSON com tool_name, tool_input, tool_use_id, error, error_type, is_interrupt e is_timeout.',
'Input to command is JSON with notification message and type.':
'A entrada para o comando é JSON com mensagem e tipo de notificação.',
'Input to command is JSON with original user prompt text.':
'A entrada para o comando é JSON com o texto original do prompt do usuário.',
'Input to command is JSON with session start source.':
'A entrada para o comando é JSON com a fonte de início da sessão.',
'Input to command is JSON with session end reason.':
'A entrada para o comando é JSON com o motivo do fim da sessão.',
'Input to command is JSON with agent_id and agent_type.':
'A entrada para o comando é JSON com agent_id e agent_type.',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'A entrada para o comando é JSON com agent_id, agent_type e agent_transcript_path.',
'Input to command is JSON with compaction details.':
'A entrada para o comando é JSON com detalhes da compactação.',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'A entrada para o comando é JSON com tool_name, tool_input e tool_use_id. Saída é JSON com hookSpecificOutput contendo decisão de permitir ou negar.',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr não exibido',
'show stderr to model and continue conversation':
'mostrar stderr ao modelo e continuar conversa',
'show stderr to user only': 'mostrar stderr apenas ao usuário',
'stdout shown in transcript mode (ctrl+o)':
'stdout exibido no modo transcrição (ctrl+o)',
'show stderr to model immediately': 'mostrar stderr ao modelo imediatamente',
'show stderr to user only but continue with tool call':
'mostrar stderr apenas ao usuário mas continuar com chamada de ferramenta',
'block processing, erase original prompt, and show stderr to user only':
'bloquear processamento, apagar prompt original e mostrar stderr apenas ao usuário',
'stdout shown to Qwen': 'stdout mostrado ao Qwen',
'show stderr to user only (blocking errors ignored)':
'mostrar stderr apenas ao usuário (erros de bloqueio ignorados)',
'command completes successfully': 'comando concluído com sucesso',
'stdout shown to subagent': 'stdout mostrado ao subagente',
'show stderr to subagent and continue having it run':
'mostrar stderr ao subagente e continuar executando',
'stdout appended as custom compact instructions':
'stdout anexado como instruções de compactação personalizadas',
'block compaction': 'bloquear compactação',
'show stderr to user only but continue with compaction':
'mostrar stderr apenas ao usuário mas continuar com compactação',
'use hook decision if provided': 'usar decisão do hook se fornecida',
// Hooks - Messages
'Config not loaded.': 'Configuração não carregada.',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'Hooks não estão ativados. Ative hooks nas configurações para usar este recurso.',
'No hooks configured. Add hooks in your settings.json file.':
'Nenhum hook configurado. Adicione hooks no seu arquivo settings.json.',
'Configured Hooks ({{count}} total)': 'Hooks Configurados ({{count}} total)',
// ============================================================================
// Commands - Session Export
@ -712,7 +826,6 @@ export default {
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'O modo de aprovação do workspace existe e tem prioridade. A alteração no nível do usuário não terá efeito.',
'Apply To': 'Aplicar A',
'User Settings': 'Configurações do Usuário',
'Workspace Settings': 'Configurações do Workspace',
// ============================================================================
@ -769,7 +882,6 @@ export default {
'List configured MCP servers and tools':
'Listar servidores e ferramentas MCP configurados',
'Restarts MCP servers.': 'Reinicia os servidores MCP.',
'Config not loaded.': 'Configuração não carregada.',
'Could not retrieve tool registry.':
'Não foi possível recuperar o registro de ferramentas.',
'No MCP servers configured with OAuth authentication.':
@ -979,7 +1091,6 @@ export default {
'No server selected': 'Nenhum servidor selecionado',
'(disabled)': '(desativado)',
'Error:': 'Erro:',
Extension: 'Extensão',
tool: 'ferramenta',
tools: 'ferramentas',
connected: 'conectado',
@ -1054,7 +1165,11 @@ export default {
"Permitir a execução de: '{{command}}'?",
'Yes, allow always ...': 'Sim, permitir sempre ...',
'Always allow in this project': 'Sempre permitir neste projeto',
'Always allow {{action}} in this project':
'Sempre permitir {{action}} neste projeto',
'Always allow for this user': 'Sempre permitir para este usuário',
'Always allow {{action}} for this user':
'Sempre permitir {{action}} 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)',

View file

@ -605,6 +605,119 @@ export default {
'List all configured hooks': 'Показать все настроенные хуки',
'Enable a disabled hook': 'Включить отключенный хук',
'Disable an active hook': 'Отключить активный хук',
// Hooks - Dialog
Hooks: 'Хуки',
'Loading hooks...': 'Загрузка хуков...',
'Error loading hooks:': 'Ошибка загрузки хуков:',
'Press Escape to close': 'Нажмите Escape для закрытия',
'No hook selected': 'Хук не выбран',
// Hooks - List Step
'No hook events found.': 'События хуков не найдены.',
'{{count}} hook configured': '{{count}} хук настроен',
'{{count}} hooks configured': '{{count}} хуков настроено',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'Это меню только для чтения. Чтобы добавить или изменить хуки, отредактируйте settings.json напрямую или спросите Qwen Code.',
'Enter to select · Esc to cancel': 'Enter для выбора · Esc для отмены',
// Hooks - Detail Step
'Exit codes:': 'Коды выхода:',
'Configured hooks:': 'Настроенные хуки:',
'No hooks configured for this event.':
'Для этого события нет настроенных хуков.',
'To add hooks, edit settings.json directly or ask Qwen.':
'Чтобы добавить хуки, отредактируйте settings.json напрямую или спросите Qwen.',
'Enter to select · Esc to go back': 'Enter для выбора · Esc для возврата',
// Hooks - Config Detail Step
'Hook details': 'Детали хука',
'Event:': 'Событие:',
'Extension:': 'Расширение:',
'Desc:': 'Описание:',
'No hook config selected': 'Конфигурация хука не выбрана',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'Чтобы изменить или удалить этот хук, отредактируйте settings.json напрямую или спросите Qwen.',
// Hooks - Source
Project: 'Проект',
User: 'Пользователь',
System: 'Система',
Extension: 'Расширение',
'Local Settings': 'Локальные настройки',
'User Settings': 'Пользовательские настройки',
'System Settings': 'Системные настройки',
Extensions: 'Расширения',
// Hooks - Status
'✓ Enabled': '✓ Включен',
'✗ Disabled': '✗ Отключен',
// Hooks - Event Descriptions (short)
'Before tool execution': 'Перед выполнением инструмента',
'After tool execution': 'После выполнения инструмента',
'After tool execution fails': 'При неудачном выполнении инструмента',
'When notifications are sent': 'При отправке уведомлений',
'When the user submits a prompt': 'Когда пользователь отправляет промпт',
'When a new session is started': 'При запуске новой сессии',
'Right before Qwen Code concludes its response':
'Непосредственно перед завершением ответа Qwen Code',
'When a subagent (Agent tool call) is started':
'При запуске субагента (вызов инструмента Agent)',
'Right before a subagent concludes its response':
'Непосредственно перед завершением ответа субагента',
'Before conversation compaction': 'Перед сжатием разговора',
'When a session is ending': 'При завершении сессии',
'When a permission dialog is displayed': 'При отображении диалога разрешений',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'Ввод в команду — это JSON аргументов вызова инструмента.',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'Ввод в команду — это JSON с полями "inputs" (аргументы вызова инструмента) и "response" (ответ вызова инструмента).',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'Ввод в команду — это JSON с tool_name, tool_input, tool_use_id, error, error_type, is_interrupt и is_timeout.',
'Input to command is JSON with notification message and type.':
'Ввод в команду — это JSON с сообщением уведомления и типом.',
'Input to command is JSON with original user prompt text.':
'Ввод в команду — это JSON с исходным текстом промпта пользователя.',
'Input to command is JSON with session start source.':
'Ввод в команду — это JSON с источником запуска сессии.',
'Input to command is JSON with session end reason.':
'Ввод в команду — это JSON с причиной завершения сессии.',
'Input to command is JSON with agent_id and agent_type.':
'Ввод в команду — это JSON с agent_id и agent_type.',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'Ввод в команду — это JSON с agent_id, agent_type и agent_transcript_path.',
'Input to command is JSON with compaction details.':
'Ввод в команду — это JSON с деталями сжатия.',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'Ввод в команду — это JSON с tool_name, tool_input и tool_use_id. Вывод — JSON с hookSpecificOutput, содержащим решение о разрешении или отказе.',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr не отображаются',
'show stderr to model and continue conversation':
'показать stderr модели и продолжить разговор',
'show stderr to user only': 'показать stderr только пользователю',
'stdout shown in transcript mode (ctrl+o)':
'stdout отображается в режиме транскрипции (ctrl+o)',
'show stderr to model immediately': 'показать stderr модели немедленно',
'show stderr to user only but continue with tool call':
'показать stderr только пользователю, но продолжить вызов инструмента',
'block processing, erase original prompt, and show stderr to user only':
'заблокировать обработку, стереть исходный промпт и показать stderr только пользователю',
'stdout shown to Qwen': 'stdout показан Qwen',
'show stderr to user only (blocking errors ignored)':
'показать stderr только пользователю (блокирующие ошибки игнорируются)',
'command completes successfully': 'команда успешно завершена',
'stdout shown to subagent': 'stdout показан субагенту',
'show stderr to subagent and continue having it run':
'показать stderr субагенту и продолжить его выполнение',
'stdout appended as custom compact instructions':
'stdout добавлен как пользовательские инструкции сжатия',
'block compaction': 'заблокировать сжатие',
'show stderr to user only but continue with compaction':
'показать stderr только пользователю, но продолжить сжатие',
'use hook decision if provided':
'использовать решение хука, если предоставлено',
// Hooks - Messages
'Config not loaded.': 'Конфигурация не загружена.',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'Хуки не включены. Включите хуки в настройках, чтобы использовать эту функцию.',
'No hooks configured. Add hooks in your settings.json file.':
'Хуки не настроены. Добавьте хуки в файл settings.json.',
'Configured Hooks ({{count}} total)': 'Настроенные хуки (всего {{count}})',
// ============================================================================
// Commands - Session Export
@ -718,7 +831,6 @@ export default {
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'Режим подтверждения рабочего пространства существует и имеет приоритет. Изменение на уровне пользователя не будет иметь эффекта.',
'Apply To': 'Применить к',
'User Settings': 'Настройки пользователя',
'Workspace Settings': 'Настройки рабочего пространства',
// ============================================================================
@ -773,7 +885,6 @@ export default {
'List configured MCP servers and tools':
'Просмотр настроенных MCP-серверов и инструментов',
'Restarts MCP servers.': 'Перезапустить MCP-серверы.',
'Config not loaded.': 'Конфигурация не загружена.',
'Could not retrieve tool registry.':
'Не удалось получить реестр инструментов.',
'No MCP servers configured with OAuth authentication.':
@ -951,7 +1062,6 @@ export default {
'View tools': 'Просмотреть инструменты',
'(disabled)': '(отключен)',
'Error:': 'Ошибка:',
Extension: 'Расширение',
tool: 'инструмент',
connected: 'подключен',
connecting: 'подключение',
@ -979,7 +1089,11 @@ export default {
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
'Yes, allow always ...': 'Да, всегда разрешать ...',
'Always allow in this project': 'Всегда разрешать в этом проекте',
'Always allow {{action}} in this project':
'Всегда разрешать {{action}} в этом проекте',
'Always allow for this user': 'Всегда разрешать для этого пользователя',
'Always allow {{action}} for this user':
'Всегда разрешать {{action}} для этого пользователя',
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',

View file

@ -632,6 +632,114 @@ export default {
'List all configured hooks': '列出所有已配置的 Hook',
'Enable a disabled hook': '启用已禁用的 Hook',
'Disable an active hook': '禁用已启用的 Hook',
// Hooks - Dialog
Hooks: 'Hook',
'Loading hooks...': '正在加载 Hook...',
'Error loading hooks:': '加载 Hook 出错:',
'Press Escape to close': '按 Escape 关闭',
'No hook selected': '未选择 Hook',
// Hooks - List Step
'No hook events found.': '未找到 Hook 事件。',
'{{count}} hook configured': '{{count}} 个 Hook 已配置',
'{{count}} hooks configured': '{{count}} 个 Hook 已配置',
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.':
'此菜单为只读。要添加或修改 Hook请直接编辑 settings.json 或询问 Qwen Code。',
'Enter to select · Esc to cancel': 'Enter 选择 · Esc 取消',
// Hooks - Detail Step
'Exit codes:': '退出码:',
'Configured hooks:': '已配置的 Hook',
'No hooks configured for this event.': '此事件未配置 Hook。',
'To add hooks, edit settings.json directly or ask Qwen.':
'要添加 Hook请直接编辑 settings.json 或询问 Qwen。',
'Enter to select · Esc to go back': 'Enter 选择 · Esc 返回',
// Hooks - Config Detail Step
'Hook details': 'Hook 详情',
'Event:': '事件:',
'Extension:': '扩展:',
'Desc:': '描述:',
'No hook config selected': '未选择 Hook 配置',
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.':
'要修改或删除此 Hook请直接编辑 settings.json 或询问 Qwen。',
// Hooks - Source
Project: '项目',
User: '用户',
System: '系统',
Extension: '扩展',
'Local Settings': '本地设置',
'User Settings': '用户设置',
'System Settings': '系统设置',
Extensions: '扩展',
// Hooks - Status
'✓ Enabled': '✓ 已启用',
'✗ Disabled': '✗ 已禁用',
// Hooks - Event Descriptions (short)
'Before tool execution': '工具执行前',
'After tool execution': '工具执行后',
'After tool execution fails': '工具执行失败后',
'When notifications are sent': '发送通知时',
'When the user submits a prompt': '用户提交提示时',
'When a new session is started': '新会话开始时',
'Right before Qwen Code concludes its response': 'Qwen Code 结束响应之前',
'When a subagent (Agent tool call) is started':
'子智能体Agent 工具调用)启动时',
'Right before a subagent concludes its response': '子智能体结束响应之前',
'Before conversation compaction': '对话压缩前',
'When a session is ending': '会话结束时',
'When a permission dialog is displayed': '显示权限对话框时',
// Hooks - Event Descriptions (detailed)
'Input to command is JSON of tool call arguments.':
'命令输入为工具调用参数的 JSON。',
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).':
'命令输入为包含 "inputs"(工具调用参数)和 "response"(工具调用响应)字段的 JSON。',
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.':
'命令输入为包含 tool_name、tool_input、tool_use_id、error、error_type、is_interrupt 和 is_timeout 的 JSON。',
'Input to command is JSON with notification message and type.':
'命令输入为包含通知消息和类型的 JSON。',
'Input to command is JSON with original user prompt text.':
'命令输入为包含原始用户提示文本的 JSON。',
'Input to command is JSON with session start source.':
'命令输入为包含会话启动来源的 JSON。',
'Input to command is JSON with session end reason.':
'命令输入为包含会话结束原因的 JSON。',
'Input to command is JSON with agent_id and agent_type.':
'命令输入为包含 agent_id 和 agent_type 的 JSON。',
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.':
'命令输入为包含 agent_id、agent_type 和 agent_transcript_path 的 JSON。',
'Input to command is JSON with compaction details.':
'命令输入为包含压缩详情的 JSON。',
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.':
'命令输入为包含 tool_name、tool_input 和 tool_use_id 的 JSON。输出包含 hookSpecificOutput 的 JSON其中包含允许或拒绝的决定。',
// Hooks - Exit Code Descriptions
'stdout/stderr not shown': 'stdout/stderr 不显示',
'show stderr to model and continue conversation':
'向模型显示 stderr 并继续对话',
'show stderr to user only': '仅向用户显示 stderr',
'stdout shown in transcript mode (ctrl+o)': 'stdout 以转录模式显示 (ctrl+o)',
'show stderr to model immediately': '立即向模型显示 stderr',
'show stderr to user only but continue with tool call':
'仅向用户显示 stderr 但继续工具调用',
'block processing, erase original prompt, and show stderr to user only':
'阻止处理,擦除原始提示,仅向用户显示 stderr',
'stdout shown to Qwen': '向 Qwen 显示 stdout',
'show stderr to user only (blocking errors ignored)':
'仅向用户显示 stderr忽略阻塞错误',
'command completes successfully': '命令成功完成',
'stdout shown to subagent': '向子智能体显示 stdout',
'show stderr to subagent and continue having it run':
'向子智能体显示 stderr 并继续运行',
'stdout appended as custom compact instructions':
'stdout 作为自定义压缩指令追加',
'block compaction': '阻止压缩',
'show stderr to user only but continue with compaction':
'仅向用户显示 stderr 但继续压缩',
'use hook decision if provided': '如果提供则使用 Hook 决定',
// Hooks - Messages
'Config not loaded.': '配置未加载。',
'Hooks are not enabled. Enable hooks in settings to use this feature.':
'Hook 未启用。请在设置中启用 Hook 以使用此功能。',
'No hooks configured. Add hooks in your settings.json file.':
'未配置 Hook。请在 settings.json 文件中添加 Hook。',
'Configured Hooks ({{count}} total)': '已配置的 Hook共 {{count}} 个)',
// ============================================================================
// Commands - Session Export
@ -732,7 +840,6 @@ export default {
'Workspace approval mode exists and takes priority. User-level change will have no effect.':
'工作区审批模式已存在并具有优先级。用户级别的更改将无效。',
'Apply To': '应用于',
'User Settings': '用户设置',
'Workspace Settings': '工作区设置',
// ============================================================================
@ -782,7 +889,6 @@ export default {
'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具',
'Restarts MCP servers.': '重启 MCP 服务器',
'Open MCP management dialog': '打开 MCP 管理对话框',
'Config not loaded.': '配置未加载',
'Could not retrieve tool registry.': '无法检索工具注册表',
'No MCP servers configured with OAuth authentication.':
'未配置支持 OAuth 认证的 MCP 服务器',
@ -841,7 +947,6 @@ export default {
'Server:': '服务器:',
'(disabled)': '(已禁用)',
'Error:': '错误:',
Extension: '扩展',
tool: '工具',
tools: '个工具',
connected: '已连接',
@ -1044,7 +1149,9 @@ export default {
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'",
'Yes, allow always ...': '是,总是允许 ...',
'Always allow in this project': '在本项目中总是允许',
'Always allow {{action}} in this project': '在本项目中总是允许{{action}}',
'Always allow for this user': '对该用户总是允许',
'Always allow {{action}} for this user': '对该用户总是允许{{action}}',
'Yes, and auto-accept edits': '是,并自动接受编辑',
'Yes, and manually approve edits': '是,并手动批准编辑',
'No, keep planning (esc)': '否,继续规划 (esc)',

View file

@ -19,6 +19,19 @@ import type { PromptPipelineContent } from './types.js';
// mirroring the logic in the actual `escapeShellArg` implementation.
function getExpectedEscapedArgForPlatform(arg: string): string {
if (os.platform() === 'win32') {
// Detect Git Bash / MSYS2 / MinTTY environments (same logic as getShellConfiguration)
const msystem = process.env['MSYSTEM'];
const term = process.env['TERM'] || '';
const isGitBash =
msystem?.startsWith('MINGW') ||
msystem?.startsWith('MSYS') ||
term.includes('msys') ||
term.includes('cygwin');
if (isGitBash) {
return quote([arg]);
}
const comSpec = (process.env['ComSpec'] || 'cmd.exe').toLowerCase();
const isPowerShell =
comSpec.endsWith('powershell.exe') || comSpec.endsWith('pwsh.exe');

View file

@ -110,6 +110,7 @@ import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
import { useMcpDialog } from './hooks/useMcpDialog.js';
import { useHooksDialog } from './hooks/useHooksDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
import {
requestConsentInteractive,
@ -552,6 +553,8 @@ export const AppContainer = (props: AppContainerProps) => {
closeExtensionsManagerDialog,
} = useExtensionsManagerDialog();
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
const { isHooksDialogOpen, openHooksDialog, closeHooksDialog } =
useHooksDialog();
const slashCommandActions = useMemo(
() => ({
@ -578,6 +581,7 @@ export const AppContainer = (props: AppContainerProps) => {
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openHooksDialog,
openResumeDialog,
}),
[
@ -597,6 +601,7 @@ export const AppContainer = (props: AppContainerProps) => {
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openHooksDialog,
openResumeDialog,
],
);
@ -1439,6 +1444,7 @@ export const AppContainer = (props: AppContainerProps) => {
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isMcpDialogOpen ||
isHooksDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isExtensionsManagerDialogOpen;
@ -1561,6 +1567,8 @@ export const AppContainer = (props: AppContainerProps) => {
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Hooks dialog
isHooksDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
@ -1662,6 +1670,8 @@ export const AppContainer = (props: AppContainerProps) => {
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Hooks dialog
isHooksDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
@ -1713,6 +1723,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Hooks dialog
openHooksDialog,
// Hooks dialog
closeHooksDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
@ -1764,6 +1778,10 @@ export const AppContainer = (props: AppContainerProps) => {
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Hooks dialog
openHooksDialog,
// Hooks dialog
closeHooksDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,

View file

@ -0,0 +1,88 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import { hooksCommand } from './hooksCommand.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
describe('hooksCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
let mockConfig: {
getHookSystem: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
// Create mock config with hook system
mockConfig = {
getHookSystem: vi.fn().mockReturnValue({
getRegistry: vi.fn().mockReturnValue({
getAllHooks: vi.fn().mockReturnValue([]),
}),
}),
};
mockContext = createMockCommandContext({
services: {
config: mockConfig,
},
});
});
describe('basic functionality', () => {
it('should open hooks management dialog in interactive mode', async () => {
const result = await hooksCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'hooks',
});
});
it('should open hooks management dialog even if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
},
});
const result = await hooksCommand.action!(contextWithoutConfig, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'hooks',
});
});
it('should open hooks management dialog even if hook system is not available', async () => {
mockConfig.getHookSystem = vi.fn().mockReturnValue(null);
const result = await hooksCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'hooks',
});
});
});
describe('non-interactive mode', () => {
it('should list hooks in non-interactive mode', async () => {
const nonInteractiveContext = createMockCommandContext({
services: {
config: mockConfig,
},
executionMode: 'non_interactive',
});
const result = await hooksCommand.action!(nonInteractiveContext, '');
// In non-interactive mode, it should return a message
expect(result).toHaveProperty('type', 'message');
});
});
});

View file

@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
@ -20,13 +20,13 @@ import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
function formatHookSource(source: string): string {
switch (source) {
case 'project':
return 'Project';
return t('Project');
case 'user':
return 'User';
return t('User');
case 'system':
return 'System';
return t('System');
case 'extensions':
return 'Extension';
return t('Extension');
default:
return source;
}
@ -36,7 +36,7 @@ function formatHookSource(source: string): string {
* Format hook status for display
*/
function formatHookStatus(enabled: boolean): string {
return enabled ? '✓ Enabled' : '✗ Disabled';
return enabled ? t('✓ Enabled') : t('✗ Disabled');
}
const listCommand: SlashCommand = {
@ -114,209 +114,27 @@ const listCommand: SlashCommand = {
},
};
const enableCommand: SlashCommand = {
name: 'enable',
get description() {
return t('Enable a disabled hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks enable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, true);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been enabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return disabled hooks for enable command (deduplicated by name)
const disabledHookNames = allHooks
.filter((hook) => !hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(disabledHookNames)];
},
};
const disableCommand: SlashCommand = {
name: 'disable',
get description() {
return t('Disable an active hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks disable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, false);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been disabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return enabled hooks for disable command (deduplicated by name)
const enabledHookNames = allHooks
.filter((hook) => hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(enabledHookNames)];
},
};
export const hooksCommand: SlashCommand = {
name: 'hooks',
get description() {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, enableCommand, disableCommand],
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
// If no subcommand provided, show list
if (!args.trim()) {
const result = await listCommand.action?.(context, '');
return result ?? { type: 'message', messageType: 'info', content: '' };
// In interactive mode, open the hooks dialog
const executionMode = context.executionMode ?? 'interactive';
if (executionMode === 'interactive') {
return {
type: 'dialog',
dialog: 'hooks',
};
}
const [subcommand, ...rest] = args.trim().split(/\s+/);
const subArgs = rest.join(' ');
let result: SlashCommandActionReturn | void;
switch (subcommand.toLowerCase()) {
case 'list':
result = await listCommand.action?.(context, subArgs);
break;
case 'enable':
result = await enableCommand.action?.(context, subArgs);
break;
case 'disable':
result = await disableCommand.action?.(context, subArgs);
break;
default:
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
{
cmd: subcommand,
},
),
};
}
// In non-interactive mode, list hooks
const result = await listCommand.action?.(context, args);
return result ?? { type: 'message', messageType: 'info', content: '' };
},
completion: async (context: CommandContext, partialArg: string) => {
const subcommands = ['list', 'enable', 'disable'];
const parts = partialArg.split(/\s+/);
if (parts.length <= 1) {
// Complete subcommand
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
}
// Complete subcommand arguments
const [subcommand, ...rest] = parts;
const subArgs = rest.join(' ');
switch (subcommand.toLowerCase()) {
case 'enable':
return enableCommand.completion?.(context, subArgs) ?? [];
case 'disable':
return disableCommand.completion?.(context, subArgs) ?? [];
default:
return [];
}
},
};

View file

@ -168,6 +168,116 @@ describe('memoryCommand', () => {
expect.any(Number),
);
});
it('should fall back to AGENTS.md when QWEN.md does not exist for --project', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('AGENTS.md')) return 'agents memory content';
throw new Error('ENOENT');
});
await projectCommand.action(mockContext, '');
const expectedPath = path.join('/test/project', 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('agents memory content'),
},
expect.any(Number),
);
});
it('should fall back to AGENTS.md when QWEN.md does not exist for --global', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
throw new Error('ENOENT');
});
await globalCommand.action(mockContext, '');
const expectedPath = path.join('/home/user', QWEN_DIR, 'AGENTS.md');
expect(mockReadFile).toHaveBeenCalledWith(expectedPath, 'utf-8');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('global agents memory'),
},
expect.any(Number),
);
});
it('should show content from both QWEN.md and AGENTS.md for --project when both exist', async () => {
const projectCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--project',
);
if (!projectCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(process, 'cwd').mockReturnValue('/test/project');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('QWEN.md')) return 'qwen memory';
if (filePath.endsWith('AGENTS.md')) return 'agents memory';
throw new Error('ENOENT');
});
await projectCommand.action(mockContext, '');
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/test/project', 'QWEN.md'),
'utf-8',
);
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/test/project', 'AGENTS.md'),
'utf-8',
);
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
expect(addItemCall.text).toContain('qwen memory');
expect(addItemCall.text).toContain('agents memory');
});
it('should show content from both files for --global when both exist', async () => {
const globalCommand = showCommand.subCommands?.find(
(cmd) => cmd.name === '--global',
);
if (!globalCommand?.action) throw new Error('Command has no action');
setGeminiMdFilename(['QWEN.md', 'AGENTS.md']);
vi.spyOn(os, 'homedir').mockReturnValue('/home/user');
mockReadFile.mockImplementation(async (filePath: string) => {
if (filePath.endsWith('QWEN.md')) return 'global qwen memory';
if (filePath.endsWith('AGENTS.md')) return 'global agents memory';
throw new Error('ENOENT');
});
await globalCommand.action(mockContext, '');
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/home/user', QWEN_DIR, 'QWEN.md'),
'utf-8',
);
expect(mockReadFile).toHaveBeenCalledWith(
path.join('/home/user', QWEN_DIR, 'AGENTS.md'),
'utf-8',
);
const addItemCall = (mockContext.ui.addItem as Mock).mock.calls[0][0];
expect(addItemCall.text).toContain('global qwen memory');
expect(addItemCall.text).toContain('global agents memory');
});
});
describe('/memory add', () => {

View file

@ -6,7 +6,7 @@
import {
getErrorMessage,
getCurrentGeminiMdFilename,
getAllGeminiMdFilenames,
loadServerHierarchicalMemory,
QWEN_DIR,
} from '@qwen-code/qwen-code-core';
@ -18,6 +18,28 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
/**
* Read all existing memory files from the configured filenames in a directory.
* Returns an array of found files with their paths and contents.
*/
async function findAllExistingMemoryFiles(
dir: string,
): Promise<Array<{ filePath: string; content: string }>> {
const results: Array<{ filePath: string; content: string }> = [];
for (const filename of getAllGeminiMdFilenames()) {
const filePath = path.join(dir, filename);
try {
const content = await fs.readFile(filePath, 'utf-8');
if (content.trim().length > 0) {
results.push({ filePath, content });
}
} catch {
// File doesn't exist, try next
}
}
return results;
}
export const memoryCommand: SlashCommand = {
name: 'memory',
get description() {
@ -56,37 +78,27 @@ export const memoryCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const workingDir =
context.services.config?.getWorkingDir?.() ?? process.cwd();
const projectMemoryPath = path.join(
workingDir,
getCurrentGeminiMdFilename(),
);
const memoryContent = await fs.readFile(
projectMemoryPath,
'utf-8',
);
const messageContent =
memoryContent.trim().length > 0
? t(
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
{
path: projectMemoryPath,
content: memoryContent,
},
)
: t('Project memory is currently empty.');
const workingDir =
context.services.config?.getWorkingDir?.() ?? process.cwd();
const results = await findAllExistingMemoryFiles(workingDir);
if (results.length > 0) {
const combined = results
.map((r) =>
t(
'Project memory content from {{path}}:\n\n---\n{{content}}\n---',
{ path: r.filePath, content: r.content },
),
)
.join('\n\n');
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
text: combined,
},
Date.now(),
);
} catch (_error) {
} else {
context.ui.addItem(
{
type: MessageType.INFO,
@ -106,32 +118,25 @@ export const memoryCommand: SlashCommand = {
},
kind: CommandKind.BUILT_IN,
action: async (context) => {
try {
const globalMemoryPath = path.join(
os.homedir(),
QWEN_DIR,
getCurrentGeminiMdFilename(),
);
const globalMemoryContent = await fs.readFile(
globalMemoryPath,
'utf-8',
);
const messageContent =
globalMemoryContent.trim().length > 0
? t('Global memory content:\n\n---\n{{content}}\n---', {
content: globalMemoryContent,
})
: t('Global memory is currently empty.');
const globalDir = path.join(os.homedir(), QWEN_DIR);
const results = await findAllExistingMemoryFiles(globalDir);
if (results.length > 0) {
const combined = results
.map((r) =>
t('Global memory content:\n\n---\n{{content}}\n---', {
content: r.content,
}),
)
.join('\n\n');
context.ui.addItem(
{
type: MessageType.INFO,
text: messageContent,
text: combined,
},
Date.now(),
);
} catch (_error) {
} else {
context.ui.addItem(
{
type: MessageType.INFO,

View file

@ -164,6 +164,7 @@ export interface OpenDialogActionReturn {
| 'approval-mode'
| 'resume'
| 'extensions_manage'
| 'hooks'
| 'mcp';
}

View file

@ -41,6 +41,7 @@ import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { HooksManagementDialog } from './hooks/HooksManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
@ -351,6 +352,9 @@ export const DialogManager = ({
/>
);
}
if (uiState.isHooksDialogOpen) {
return <HooksManagementDialog onClose={uiActions.closeHooksDialog} />;
}
if (uiState.isMcpDialogOpen) {
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
}

View file

@ -0,0 +1,280 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import {
HookEventName,
HooksConfigSource,
HookType,
} from '@qwen-code/qwen-code-core';
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
import type { HookEventDisplayInfo, HookConfigDisplayInfo } from './types.js';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string) => key),
}));
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
}));
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'white',
secondary: 'gray',
accent: 'cyan',
},
border: {
default: 'gray',
},
},
}));
describe('HookConfigDetailStep', () => {
const createMockHookEvent = (): HookEventDisplayInfo => ({
event: HookEventName.Stop,
shortDescription: 'Right before Qwen Code concludes its response',
description: '',
exitCodes: [
{ code: 0, description: 'stdout/stderr not shown' },
{
code: 2,
description: 'show stderr to model and continue conversation',
},
{ code: 'Other', description: 'show stderr to user only' },
],
configs: [],
});
const createMockHookConfig = (
source: HooksConfigSource = HooksConfigSource.User,
sourceDisplay = 'User Settings',
sourcePath?: string,
): HookConfigDisplayInfo => ({
config: {
type: HookType.Command,
command: '/path/to/hook.sh',
},
source,
sourceDisplay,
sourcePath,
enabled: true,
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should render hook details title', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Hook details');
});
it('should render event name', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Event:');
expect(lastFrame()).toContain(HookEventName.Stop);
});
it('should render hook type', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Type:');
expect(lastFrame()).toContain('command');
});
it('should render source for User Settings', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Source:');
expect(lastFrame()).toContain('User Settings');
});
it('should render source for Local Settings', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig(HooksConfigSource.Project);
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Local Settings');
});
it('should render source for Extensions with path', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig(
HooksConfigSource.Extensions,
'ralph-wiggum',
'/Users/test/.qwen/extensions/ralph-wiggum',
);
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Extensions');
expect(lastFrame()).toContain('/Users/test/.qwen/extensions/ralph-wiggum');
});
it('should render Extension field for extensions', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig(
HooksConfigSource.Extensions,
'ralph-wiggum',
);
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Extension:');
expect(lastFrame()).toContain('ralph-wiggum');
});
it('should not render Extension field for non-extensions', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig(HooksConfigSource.User);
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
// Should not have Extension label for User Settings
const output = lastFrame();
const extensionMatch = output?.match(/Extension:/g);
expect(extensionMatch).toBeNull();
});
it('should render command', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Command:');
expect(lastFrame()).toContain('/path/to/hook.sh');
});
it('should render hook name if present', () => {
const hookEvent = createMockHookEvent();
const hookConfig: HookConfigDisplayInfo = {
config: {
type: HookType.Command,
command: '/path/to/hook.sh',
name: 'My Hook',
},
source: HooksConfigSource.User,
sourceDisplay: 'User Settings',
enabled: true,
};
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Name:');
expect(lastFrame()).toContain('My Hook');
});
it('should render hook description if present', () => {
const hookEvent = createMockHookEvent();
const hookConfig: HookConfigDisplayInfo = {
config: {
type: HookType.Command,
command: '/path/to/hook.sh',
description: 'A test hook',
},
source: HooksConfigSource.User,
sourceDisplay: 'User Settings',
enabled: true,
};
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Desc:');
expect(lastFrame()).toContain('A test hook');
});
it('should render help text', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('To modify or remove this hook');
});
it('should render Esc hint', () => {
const hookEvent = createMockHookEvent();
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain('Esc to go back');
});
it('should handle different event types', () => {
const events = [
HookEventName.PreToolUse,
HookEventName.PostToolUse,
HookEventName.UserPromptSubmit,
HookEventName.SessionStart,
];
for (const event of events) {
const hookEvent: HookEventDisplayInfo = {
event,
shortDescription: 'Test',
description: '',
exitCodes: [],
configs: [],
};
const hookConfig = createMockHookConfig();
const { lastFrame } = render(
<HookConfigDetailStep hookEvent={hookEvent} hookConfig={hookConfig} />,
);
expect(lastFrame()).toContain(event);
}
});
});

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookConfigDisplayInfo, HookEventDisplayInfo } from './types.js';
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
import { t } from '../../../i18n/index.js';
interface HookConfigDetailStepProps {
hookEvent: HookEventDisplayInfo;
hookConfig: HookConfigDisplayInfo;
}
export function HookConfigDetailStep({
hookEvent,
hookConfig,
}: HookConfigDetailStepProps): React.JSX.Element {
const { columns: terminalWidth } = useTerminalSize();
// Get source display
const getSourceDisplay = (): string => {
switch (hookConfig.source) {
case HooksConfigSource.Project:
return t('Local Settings');
case HooksConfigSource.User:
return t('User Settings');
case HooksConfigSource.System:
return t('System Settings');
case HooksConfigSource.Extensions:
return t('Extensions');
default:
return hookConfig.source;
}
};
// Check if this is from an extension
const isFromExtension = hookConfig.source === HooksConfigSource.Extensions;
// Get hook type display
const getHookTypeDisplay = (): string => {
switch (hookConfig.config.type) {
case 'command':
return 'command';
default:
return hookConfig.config.type;
}
};
// Get command to display
const getCommand = (): string => {
if (hookConfig.config.type === 'command') {
return hookConfig.config.command;
}
return '';
};
// Calculate box width for command display
const commandBoxWidth = Math.min(terminalWidth - 6, 80);
// Label width for alignment (Extension: is the longest label)
const labelWidth = 12;
return (
<Box flexDirection="column" paddingX={1}>
{/* Title */}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Hook details')}
</Text>
</Box>
{/* Event */}
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Event:')}</Text>
</Box>
<Text color={theme.text.primary}>{hookEvent.event}</Text>
</Box>
{/* Type */}
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Type:')}</Text>
</Box>
<Text color={theme.text.primary}>{getHookTypeDisplay()}</Text>
</Box>
{/* Source */}
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Source:')}</Text>
</Box>
<Text color={theme.text.primary}>{getSourceDisplay()}</Text>
{hookConfig.sourcePath && (
<Text color={theme.text.secondary}> ({hookConfig.sourcePath})</Text>
)}
</Box>
{/* Extension name (only for extensions) */}
{isFromExtension && hookConfig.sourceDisplay && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Extension:')}</Text>
</Box>
<Text color={theme.text.primary}>{hookConfig.sourceDisplay}</Text>
</Box>
)}
{/* Name (if exists) */}
{hookConfig.config.name && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Name:')}</Text>
</Box>
<Text color={theme.text.primary}>{hookConfig.config.name}</Text>
</Box>
)}
{/* Description (if exists) */}
{hookConfig.config.description && (
<Box>
<Box width={labelWidth}>
<Text color={theme.text.secondary}>{t('Desc:')}</Text>
</Box>
<Text color={theme.text.primary}>
{hookConfig.config.description}
</Text>
</Box>
)}
{/* Command */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Command:')}</Text>
</Box>
{/* Command box */}
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
width={commandBoxWidth}
>
<Text color={theme.text.primary}>{getCommand()}</Text>
</Box>
{/* Help text */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t(
'To modify or remove this hook, edit settings.json directly or ask Qwen to help.',
)}
</Text>
</Box>
{/* Footer hint */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,228 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import {
HookEventName,
HooksConfigSource,
HookType,
} from '@qwen-code/qwen-code-core';
import { HookDetailStep } from './HookDetailStep.js';
import type { HookEventDisplayInfo } from './types.js';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string) => key),
}));
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 24 })),
}));
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'white',
secondary: 'gray',
accent: 'cyan',
},
status: {
success: 'green',
error: 'red',
},
},
}));
describe('HookDetailStep', () => {
const createMockHookInfo = (
event: HookEventName,
configCount = 0,
hasDescription = true,
): HookEventDisplayInfo => ({
event,
shortDescription: `Short description for ${event}`,
description: hasDescription ? `Detailed description for ${event}` : '',
exitCodes: [
{ code: 0, description: 'Success' },
{ code: 2, description: 'Block' },
],
configs: Array(configCount)
.fill(null)
.map((_, i) => ({
config: { command: `hook-command-${i}`, type: HookType.Command },
source:
i % 2 === 0 ? HooksConfigSource.User : HooksConfigSource.Project,
sourceDisplay: i % 2 === 0 ? 'User Settings' : 'Local Settings',
enabled: true,
})),
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should render hook event name as title', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain(HookEventName.PreToolUse);
});
it('should render description when present', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0, true);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain('Detailed description for PreToolUse');
});
it('should not render description section when empty', () => {
const hook = createMockHookInfo(HookEventName.Stop, 0, false);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
// Stop event has empty description
const output = lastFrame();
expect(output).toContain(HookEventName.Stop);
});
it('should render exit codes', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('Exit codes');
expect(output).toContain('0');
expect(output).toContain('Success');
expect(output).toContain('2');
expect(output).toContain('Block');
});
it('should show empty state when no configs', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 0);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('No hooks configured for this event');
expect(output).toContain('To add hooks, edit settings.json');
});
it('should show configured hooks list when configs exist', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('Configured hooks');
expect(output).toContain('[command]');
expect(output).toContain('hook-command-0');
expect(output).toContain('hook-command-1');
});
it('should show source display for each config', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 2);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('User Settings');
expect(output).toContain('Local Settings');
});
it('should show selection indicator for first config', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse, 3);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('');
});
it('should show keyboard hint for going back', () => {
const hook = createMockHookInfo(HookEventName.PreToolUse);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain('Esc to go back');
});
it('should render with multiple configs', () => {
const hook = createMockHookInfo(HookEventName.PostToolUse, 5);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('1.');
expect(output).toContain('2.');
expect(output).toContain('3.');
expect(output).toContain('4.');
expect(output).toContain('5.');
});
it('should handle hook with no exit codes', () => {
const hook: HookEventDisplayInfo = {
event: HookEventName.PreToolUse,
shortDescription: 'Test',
description: 'Test description',
exitCodes: [],
configs: [],
};
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).not.toContain('Exit codes');
});
it('should handle different hook event types', () => {
const events = [
HookEventName.Stop,
HookEventName.PreToolUse,
HookEventName.PostToolUse,
HookEventName.UserPromptSubmit,
HookEventName.SessionStart,
HookEventName.SessionEnd,
];
for (const event of events) {
const hook = createMockHookInfo(event, 1);
const { lastFrame } = render(
<HookDetailStep hook={hook} selectedIndex={0} />,
);
expect(lastFrame()).toContain(event);
}
});
});

View file

@ -0,0 +1,150 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookEventDisplayInfo } from './types.js';
import { HooksConfigSource } from '@qwen-code/qwen-code-core';
import { getTranslatedSourceDisplayMap } from './constants.js';
import { t } from '../../../i18n/index.js';
interface HookDetailStepProps {
hook: HookEventDisplayInfo;
selectedIndex: number;
}
export function HookDetailStep({
hook,
selectedIndex,
}: HookDetailStepProps): React.JSX.Element {
const hasConfigs = hook.configs.length > 0;
const { columns: terminalWidth } = useTerminalSize();
// Get translated source display map
const sourceDisplayMap = getTranslatedSourceDisplayMap();
// Calculate column widths (command: 70%, source: 30%)
const commandWidth = Math.floor(terminalWidth * 0.65);
const sourceWidth = Math.floor(terminalWidth * 0.3);
// Get source display for config list
const getConfigSourceDisplay = (config: {
source: HooksConfigSource;
sourceDisplay: string;
}): string => {
if (config.source === HooksConfigSource.Extensions) {
// For extensions, sourceDisplay is the extension name
return `${sourceDisplayMap[HooksConfigSource.Extensions]} (${config.sourceDisplay})`;
}
return sourceDisplayMap[config.source] || config.source;
};
return (
<Box flexDirection="column" paddingX={1}>
{/* Title */}
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{hook.event}
</Text>
</Box>
{/* Description */}
{hook.description && (
<Box marginBottom={1}>
<Text color={theme.text.secondary}>{hook.description}</Text>
</Box>
)}
{/* Exit codes */}
{hook.exitCodes.length > 0 && (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Exit codes:')}
</Text>
{hook.exitCodes.map((ec, index) => (
<Box key={index}>
<Text color={theme.text.secondary}>
{` ${ec.code}: ${ec.description}`}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1} />
{/* Configs or empty state */}
{hasConfigs ? (
<>
<Text bold color={theme.text.primary}>
{t('Configured hooks:')}
</Text>
{hook.configs.map((config, index) => {
const isSelected = index === selectedIndex;
const sourceDisplay = getConfigSourceDisplay(config);
const command =
config.config.type === 'command' ? config.config.command : '';
const hookType = config.config.type;
return (
<Box key={index}>
{/* Left column: selector + command */}
<Box width={commandWidth}>
<Box minWidth={2}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{isSelected ? '' : ' '}
</Text>
</Box>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
wrap="wrap"
>
{`${index + 1}. [${hookType}] ${command}`}
</Text>
</Box>
{/* Spacer between columns */}
<Box width={2} />
{/* Right column: source */}
<Box width={sourceWidth}>
<Text color={theme.text.secondary} wrap="wrap">
{sourceDisplay}
</Text>
</Box>
</Box>
);
})}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to select · Esc to go back')}
</Text>
</Box>
</>
) : (
<>
<Box>
<Text color={theme.text.secondary}>
{t('No hooks configured for this event.')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('To add hooks, edit settings.json directly or ask Qwen.')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to go back')}</Text>
</Box>
</>
)}
</Box>
);
}

View file

@ -0,0 +1,197 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import {
HookEventName,
HookType,
HooksConfigSource,
} from '@qwen-code/qwen-code-core';
import { HooksListStep } from './HooksListStep.js';
import type { HookEventDisplayInfo } from './types.js';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string, options?: { count?: string }) => {
// Handle pluralization
if (key === '{{count}} hook configured' && options?.count) {
return `${options.count} hook configured`;
}
if (key === '{{count}} hooks configured' && options?.count) {
return `${options.count} hooks configured`;
}
return key;
}),
}));
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
}));
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'white',
secondary: 'gray',
accent: 'cyan',
},
status: {
success: 'green',
error: 'red',
},
},
}));
describe('HooksListStep', () => {
const createMockHookInfo = (
event: HookEventName,
configCount = 0,
): HookEventDisplayInfo => ({
event,
shortDescription: `Description for ${event}`,
description: `Detailed description for ${event}`,
exitCodes: [
{ code: 0, description: 'Success' },
{ code: 2, description: 'Block' },
],
configs: Array(configCount)
.fill(null)
.map((_, i) => ({
config: { command: `hook-${i}`, type: HookType.Command },
source: HooksConfigSource.User,
sourceDisplay: 'User Settings',
enabled: true,
})),
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should render empty state when no hooks', () => {
const { lastFrame } = render(
<HooksListStep hooks={[]} selectedIndex={0} />,
);
expect(lastFrame()).toContain('No hook events found');
});
it('should render list of hooks', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse),
createMockHookInfo(HookEventName.PostToolUse),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('Hooks');
expect(output).toContain(HookEventName.PreToolUse);
expect(output).toContain(HookEventName.PostToolUse);
});
it('should show config count for hooks with configs', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse, 3),
createMockHookInfo(HookEventName.PostToolUse, 0),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('(3)');
expect(output).not.toContain('(0)');
});
it('should show singular form for single hook', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse, 1),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('1 hook configured');
});
it('should show read-only message', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('read-only');
expect(output).toContain('settings.json');
});
it('should show keyboard hints', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('Enter to select');
expect(output).toContain('Esc to cancel');
});
it('should show selection indicator for first item', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse),
createMockHookInfo(HookEventName.PostToolUse),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('');
});
it('should display hook short descriptions', () => {
const hooks: HookEventDisplayInfo[] = [
createMockHookInfo(HookEventName.PreToolUse),
];
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain('Description for PreToolUse');
});
it('should pad index numbers based on total count', () => {
const hooks: HookEventDisplayInfo[] = Array(10)
.fill(null)
.map((_, i) => createMockHookInfo(`${i}` as HookEventName));
const { lastFrame } = render(
<HooksListStep hooks={hooks} selectedIndex={0} />,
);
const output = lastFrame();
expect(output).toContain(' 1.');
expect(output).toContain('10.');
});
});

View file

@ -0,0 +1,103 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { HookEventDisplayInfo } from './types.js';
import { t } from '../../../i18n/index.js';
interface HooksListStepProps {
hooks: HookEventDisplayInfo[];
selectedIndex: number;
}
export function HooksListStep({
hooks,
selectedIndex,
}: HooksListStepProps): React.JSX.Element {
const { columns: terminalWidth } = useTerminalSize();
// Calculate responsive width for hook name column (min 20, max 35)
const hookNameWidth = Math.min(
35,
Math.max(20, Math.floor(terminalWidth * 0.25)),
);
if (hooks.length === 0) {
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>{t('No hook events found.')}</Text>
</Box>
);
}
// Calculate total configured hooks
const totalConfigured = hooks.reduce(
(sum, hook) => sum + hook.configs.length,
0,
);
// Get the correct plural/singular form
const hooksConfiguredText =
totalConfigured === 1
? t('{{count}} hook configured', { count: String(totalConfigured) })
: t('{{count}} hooks configured', { count: String(totalConfigured) });
return (
<Box flexDirection="column" paddingX={1}>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
{t('Hooks')}
</Text>
<Text color={theme.text.secondary}>{` · ${hooksConfiguredText}`}</Text>
</Box>
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{t(
'This menu is read-only. To add or modify hooks, edit settings.json directly or ask Qwen Code.',
)}
</Text>
</Box>
{hooks.map((hook, index) => {
const isSelected = index === selectedIndex;
const configCount = hook.configs.length;
const maxDigits = String(hooks.length).length;
const paddedIndex = String(index + 1).padStart(maxDigits);
return (
<Box key={hook.event}>
<Box minWidth={2}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '' : ' '}
</Text>
</Box>
<Box width={hookNameWidth}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
bold={isSelected}
>
{paddedIndex}. {hook.event}
{configCount > 0 && (
<Text color={theme.status.success}> ({configCount})</Text>
)}
</Text>
</Box>
<Text color={theme.text.secondary}>{hook.shortDescription}</Text>
</Box>
);
})}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Enter to select · Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,127 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HooksManagementDialog } from './HooksManagementDialog.js';
import { renderWithProviders } from '../../../test-utils/render.js';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string, options?: { count?: string }) => {
// Handle pluralization
if (key === '{{count}} hook configured' && options?.count) {
return `${options.count} hook configured`;
}
if (key === '{{count}} hooks configured' && options?.count) {
return `${options.count} hooks configured`;
}
return key;
}),
}));
// Mock useTerminalSize
vi.mock('../../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 24 })),
}));
// Mock useConfig
vi.mock('../../contexts/ConfigContext.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../contexts/ConfigContext.js')>();
return {
...actual,
useConfig: vi.fn(() => ({
getExtensions: vi.fn(() => []),
})),
};
});
// Mock loadSettings
vi.mock('../../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../config/settings.js')>();
return {
...actual,
loadSettings: vi.fn(() => ({
forScope: vi.fn(() => ({ settings: {} })),
})),
};
});
// Mock semantic-colors
vi.mock('../../semantic-colors.js', () => ({
theme: {
text: {
primary: 'white',
secondary: 'gray',
accent: 'cyan',
},
status: {
success: 'green',
error: 'red',
},
border: {
default: 'gray',
},
},
}));
// Mock createDebugLogger
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
return {
...actual,
createDebugLogger: vi.fn(() => ({
log: vi.fn(),
error: vi.fn(),
})),
};
});
describe('HooksManagementDialog', () => {
const mockOnClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it('should render loading state initially', () => {
const { lastFrame } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
expect(lastFrame()).toContain('Loading hooks');
});
it('should render with border', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
// The dialog should have a border (rendered as box-drawing characters)
const output = lastFrame();
expect(output).toBeTruthy();
unmount();
});
it('should handle empty hooks list gracefully', async () => {
const { lastFrame, unmount } = renderWithProviders(
<HooksManagementDialog onClose={mockOnClose} />,
);
await new Promise((resolve) => setTimeout(resolve, 100));
const output = lastFrame();
// Should show 0 hooks configured when no hooks are configured
expect(output).toContain('0 hooks configured');
unmount();
});
});

View file

@ -0,0 +1,421 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import {
HooksConfigSource,
type HookDefinition,
type HookConfig,
createDebugLogger,
HOOKS_CONFIG_FIELDS,
} from '@qwen-code/qwen-code-core';
import type {
HooksManagementDialogProps,
HookEventDisplayInfo,
} from './types.js';
import { HOOKS_MANAGEMENT_STEPS } from './types.js';
import { HooksListStep } from './HooksListStep.js';
import { HookDetailStep } from './HookDetailStep.js';
import { HookConfigDetailStep } from './HookConfigDetailStep.js';
import {
DISPLAY_HOOK_EVENTS,
getTranslatedSourceDisplayMap,
createEmptyHookEventInfo,
} from './constants.js';
import { t } from '../../../i18n/index.js';
const debugLogger = createDebugLogger('HOOKS_DIALOG');
/**
* Type guard to check if a value is a valid HookConfig
*/
function isValidHookConfig(config: unknown): config is HookConfig {
return (
typeof config === 'object' &&
config !== null &&
'type' in config &&
'command' in config &&
typeof (config as HookConfig).command === 'string'
);
}
/**
* Type guard to check if a value is a valid HookDefinition
*/
function isValidHookDefinition(def: unknown): def is HookDefinition {
if (typeof def !== 'object' || def === null) {
return false;
}
const obj = def as Record<string, unknown>;
// hooks array is required
if (!('hooks' in obj) || !Array.isArray(obj['hooks'])) {
return false;
}
// Validate each hook config in the array
for (const hook of obj['hooks']) {
if (!isValidHookConfig(hook)) {
return false;
}
}
// matcher is optional but must be a string if present
if ('matcher' in obj && typeof obj['matcher'] !== 'string') {
return false;
}
// sequential is optional but must be a boolean if present
if ('sequential' in obj && typeof obj['sequential'] !== 'boolean') {
return false;
}
return true;
}
/**
* Type guard to check if a value is a valid hooks record
*/
function isValidHooksRecord(
hooks: unknown,
): hooks is Record<string, HookDefinition[]> {
if (typeof hooks !== 'object' || hooks === null) {
return false;
}
const record = hooks as Record<string, unknown>;
for (const [key, value] of Object.entries(record)) {
// Skip non-event configuration fields
if (HOOKS_CONFIG_FIELDS.includes(key)) {
continue;
}
if (!Array.isArray(value)) {
return false;
}
for (const def of value) {
if (!isValidHookDefinition(def)) {
return false;
}
}
}
return true;
}
export function HooksManagementDialog({
onClose,
}: HooksManagementDialogProps): React.JSX.Element {
const config = useConfig();
const { columns: width } = useTerminalSize();
const boxWidth = width - 4;
const [navigationStack, setNavigationStack] = useState<string[]>([
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST,
]);
const [selectedHookIndex, setSelectedHookIndex] = useState<number>(-1);
const [selectedConfigIndex, setSelectedConfigIndex] = useState<number>(-1);
// Track selected index within each step for keyboard navigation
const [listSelectedIndex, setListSelectedIndex] = useState<number>(0);
const [detailSelectedIndex, setDetailSelectedIndex] = useState<number>(0);
const [hooks, setHooks] = useState<HookEventDisplayInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
// Current step
const currentStep =
navigationStack[navigationStack.length - 1] ||
HOOKS_MANAGEMENT_STEPS.HOOKS_LIST;
// Selected hook event
const selectedHook = useMemo(() => {
if (selectedHookIndex >= 0 && selectedHookIndex < hooks.length) {
return hooks[selectedHookIndex];
}
return null;
}, [hooks, selectedHookIndex]);
// Centralized keyboard handler
useKeypress(
(key) => {
if (isLoading || loadError) {
// Allow Escape to close even during loading/error states
if (key.name === 'escape') {
onClose();
}
return;
}
switch (currentStep) {
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
if (key.name === 'up') {
setListSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setListSelectedIndex((prev) =>
Math.min(hooks.length - 1, prev + 1),
);
} else if (key.name === 'return') {
if (hooks.length > 0 && listSelectedIndex >= 0) {
setSelectedHookIndex(listSelectedIndex);
setSelectedConfigIndex(-1);
setDetailSelectedIndex(0);
setNavigationStack((prev) => [
...prev,
HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL,
]);
}
} else if (key.name === 'escape') {
onClose();
}
break;
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
if (key.name === 'escape') {
handleNavigateBack();
} else if (selectedHook && selectedHook.configs.length > 0) {
if (key.name === 'up') {
setDetailSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setDetailSelectedIndex((prev) =>
Math.min(selectedHook.configs.length - 1, prev + 1),
);
} else if (key.name === 'return') {
setSelectedConfigIndex(detailSelectedIndex);
setNavigationStack((prev) => [
...prev,
HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL,
]);
}
}
break;
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
if (key.name === 'escape') {
handleNavigateBack();
}
break;
default:
// No action for unknown steps
break;
}
},
{ isActive: true },
);
// Load hooks data
const fetchHooksData = useCallback((): HookEventDisplayInfo[] => {
if (!config) return [];
const settings = loadSettings();
const userSettings = settings.forScope(SettingScope.User).settings;
const workspaceSettings = settings.forScope(
SettingScope.Workspace,
).settings;
// Get translated source display map
const sourceDisplayMap = getTranslatedSourceDisplayMap();
const result: HookEventDisplayInfo[] = [];
for (const eventName of DISPLAY_HOOK_EVENTS) {
const hookInfo = createEmptyHookEventInfo(eventName);
// Get hooks from user settings (with type validation)
const userSettingsRecord = userSettings as Record<string, unknown>;
const userHooksRaw = userSettingsRecord?.['hooks'];
if (isValidHooksRecord(userHooksRaw) && userHooksRaw[eventName]) {
for (const def of userHooksRaw[eventName]) {
for (const hookConfig of def.hooks) {
hookInfo.configs.push({
config: hookConfig,
source: HooksConfigSource.User,
sourceDisplay: sourceDisplayMap[HooksConfigSource.User],
enabled: true,
});
}
}
}
// Get hooks from workspace settings (with type validation)
const workspaceSettingsRecord = workspaceSettings as Record<
string,
unknown
>;
const workspaceHooksRaw = workspaceSettingsRecord?.['hooks'];
if (
isValidHooksRecord(workspaceHooksRaw) &&
workspaceHooksRaw[eventName]
) {
for (const def of workspaceHooksRaw[eventName]) {
for (const hookConfig of def.hooks) {
hookInfo.configs.push({
config: hookConfig,
source: HooksConfigSource.Project,
sourceDisplay: sourceDisplayMap[HooksConfigSource.Project],
enabled: true,
});
}
}
}
// Get hooks from extensions (with type validation)
const extensions = config.getExtensions() || [];
for (const extension of extensions) {
if (extension.isActive && extension.hooks?.[eventName]) {
const extensionHooks = extension.hooks[eventName];
if (Array.isArray(extensionHooks)) {
for (const def of extensionHooks) {
if (isValidHookDefinition(def)) {
for (const hookConfig of def.hooks) {
hookInfo.configs.push({
config: hookConfig,
source: HooksConfigSource.Extensions,
sourceDisplay: extension.name,
sourcePath: extension.path,
enabled: true,
});
}
}
}
}
}
}
result.push(hookInfo);
}
return result;
}, [config]);
// Load hooks data on initial render
useEffect(() => {
let cancelled = false;
setIsLoading(true);
setLoadError(null);
try {
const hooksData = fetchHooksData();
if (!cancelled) {
setHooks(hooksData);
}
} catch (error) {
if (!cancelled) {
debugLogger.error('Error loading hooks:', error);
setLoadError(
error instanceof Error ? error.message : 'Failed to load hooks',
);
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
return () => {
cancelled = true;
};
}, [fetchHooksData]);
// Navigation handler for going back
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) {
onClose();
return prev;
}
return prev.slice(0, -1);
});
}, [onClose]);
// Selected hook config
const selectedConfig = useMemo(() => {
if (
selectedHook &&
selectedConfigIndex >= 0 &&
selectedConfigIndex < selectedHook.configs.length
) {
return selectedHook.configs[selectedConfigIndex];
}
return null;
}, [selectedHook, selectedConfigIndex]);
// Render based on current step
const renderContent = () => {
if (isLoading) {
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>{t('Loading hooks...')}</Text>
</Box>
);
}
if (loadError) {
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.status.error}>{t('Error loading hooks:')}</Text>
<Text color={theme.text.secondary}>{loadError}</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Press Escape to close')}
</Text>
</Box>
</Box>
);
}
switch (currentStep) {
case HOOKS_MANAGEMENT_STEPS.HOOKS_LIST:
return (
<HooksListStep hooks={hooks} selectedIndex={listSelectedIndex} />
);
case HOOKS_MANAGEMENT_STEPS.HOOK_DETAIL:
if (selectedHook) {
return (
<HookDetailStep
hook={selectedHook}
selectedIndex={detailSelectedIndex}
/>
);
}
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>{t('No hook selected')}</Text>
</Box>
);
case HOOKS_MANAGEMENT_STEPS.HOOK_CONFIG_DETAIL:
if (selectedHook && selectedConfig) {
return (
<HookConfigDetailStep
hookEvent={selectedHook}
hookConfig={selectedConfig}
/>
);
}
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>
{t('No hook config selected')}
</Text>
</Box>
);
default:
return null;
}
};
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
width={boxWidth}
paddingX={1}
paddingY={1}
>
{renderContent()}
</Box>
);
}

View file

@ -0,0 +1,219 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookEventName, HooksConfigSource } from '@qwen-code/qwen-code-core';
// Mock i18n module
vi.mock('../../../i18n/index.js', () => ({
t: vi.fn((key: string) => key),
}));
// Import after mocking
import {
getHookExitCodes,
getHookShortDescription,
getHookDescription,
getTranslatedSourceDisplayMap,
createEmptyHookEventInfo,
DISPLAY_HOOK_EVENTS,
} from './constants.js';
describe('hooks constants', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getHookExitCodes', () => {
it('should return exit codes for Stop event', () => {
const exitCodes = getHookExitCodes(HookEventName.Stop);
expect(exitCodes).toHaveLength(3);
expect(exitCodes[0]).toEqual({
code: 0,
description: expect.any(String),
});
expect(exitCodes[1]).toEqual({
code: 2,
description: expect.any(String),
});
expect(exitCodes[2]).toEqual({
code: 'Other',
description: expect.any(String),
});
});
it('should return exit codes for PreToolUse event', () => {
const exitCodes = getHookExitCodes(HookEventName.PreToolUse);
expect(exitCodes).toHaveLength(3);
expect(exitCodes[0].code).toBe(0);
expect(exitCodes[1].code).toBe(2);
expect(exitCodes[2].code).toBe('Other');
});
it('should return exit codes for PostToolUse event', () => {
const exitCodes = getHookExitCodes(HookEventName.PostToolUse);
expect(exitCodes).toHaveLength(3);
});
it('should return exit codes for UserPromptSubmit event', () => {
const exitCodes = getHookExitCodes(HookEventName.UserPromptSubmit);
expect(exitCodes).toHaveLength(3);
});
it('should return exit codes for Notification event', () => {
const exitCodes = getHookExitCodes(HookEventName.Notification);
expect(exitCodes).toHaveLength(2);
});
it('should return exit codes for SessionStart event', () => {
const exitCodes = getHookExitCodes(HookEventName.SessionStart);
expect(exitCodes).toHaveLength(2);
});
it('should return exit codes for SessionEnd event', () => {
const exitCodes = getHookExitCodes(HookEventName.SessionEnd);
expect(exitCodes).toHaveLength(2);
});
it('should return exit codes for PreCompact event', () => {
const exitCodes = getHookExitCodes(HookEventName.PreCompact);
expect(exitCodes).toHaveLength(3);
});
it('should return empty array for unknown event', () => {
const exitCodes = getHookExitCodes('unknown_event' as HookEventName);
expect(exitCodes).toEqual([]);
});
});
describe('getHookShortDescription', () => {
it('should return description for PreToolUse', () => {
const desc = getHookShortDescription(HookEventName.PreToolUse);
expect(desc).toBe('Before tool execution');
});
it('should return description for PostToolUse', () => {
const desc = getHookShortDescription(HookEventName.PostToolUse);
expect(desc).toBe('After tool execution');
});
it('should return description for UserPromptSubmit', () => {
const desc = getHookShortDescription(HookEventName.UserPromptSubmit);
expect(desc).toBe('When the user submits a prompt');
});
it('should return description for SessionStart', () => {
const desc = getHookShortDescription(HookEventName.SessionStart);
expect(desc).toBe('When a new session is started');
});
it('should return empty string for unknown event', () => {
const desc = getHookShortDescription('unknown_event' as HookEventName);
expect(desc).toBe('');
});
});
describe('getHookDescription', () => {
it('should return description for PreToolUse', () => {
const desc = getHookDescription(HookEventName.PreToolUse);
expect(desc).toBe('Input to command is JSON of tool call arguments.');
});
it('should return description for PostToolUse', () => {
const desc = getHookDescription(HookEventName.PostToolUse);
expect(desc).toContain('inputs');
expect(desc).toContain('response');
});
it('should return empty string for Stop event', () => {
const desc = getHookDescription(HookEventName.Stop);
expect(desc).toBe('');
});
it('should return empty string for unknown event', () => {
const desc = getHookDescription('unknown_event' as HookEventName);
expect(desc).toBe('');
});
});
describe('getTranslatedSourceDisplayMap', () => {
it('should return mapping for all sources', () => {
const map = getTranslatedSourceDisplayMap();
expect(map[HooksConfigSource.Project]).toBe('Local Settings');
expect(map[HooksConfigSource.User]).toBe('User Settings');
expect(map[HooksConfigSource.System]).toBe('System Settings');
expect(map[HooksConfigSource.Extensions]).toBe('Extensions');
});
it('should return translated strings', () => {
const map = getTranslatedSourceDisplayMap();
// All values should be strings (translated)
Object.values(map).forEach((value) => {
expect(typeof value).toBe('string');
expect(value.length).toBeGreaterThan(0);
});
});
});
describe('DISPLAY_HOOK_EVENTS', () => {
it('should contain all expected hook events', () => {
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Stop);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreToolUse);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUse);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUseFailure);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Notification);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.UserPromptSubmit);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionStart);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SessionEnd);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStart);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStop);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreCompact);
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PermissionRequest);
});
it('should have 12 events', () => {
expect(DISPLAY_HOOK_EVENTS).toHaveLength(12);
});
});
describe('createEmptyHookEventInfo', () => {
it('should create empty info for PreToolUse', () => {
const info = createEmptyHookEventInfo(HookEventName.PreToolUse);
expect(info.event).toBe(HookEventName.PreToolUse);
expect(info.shortDescription).toBe('Before tool execution');
expect(info.description).toBe(
'Input to command is JSON of tool call arguments.',
);
expect(info.exitCodes).toHaveLength(3);
expect(info.configs).toEqual([]);
});
it('should create empty info for Stop', () => {
const info = createEmptyHookEventInfo(HookEventName.Stop);
expect(info.event).toBe(HookEventName.Stop);
expect(info.shortDescription).toBe(
'Right before Qwen Code concludes its response',
);
expect(info.description).toBe('');
expect(info.exitCodes).toHaveLength(3);
expect(info.configs).toEqual([]);
});
it('should create empty info for unknown event', () => {
const info = createEmptyHookEventInfo('unknown_event' as HookEventName);
expect(info.event).toBe('unknown_event');
expect(info.shortDescription).toBe('');
expect(info.description).toBe('');
expect(info.exitCodes).toEqual([]);
expect(info.configs).toEqual([]);
});
});
});

View file

@ -0,0 +1,217 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { HooksConfigSource, HookEventName } from '@qwen-code/qwen-code-core';
import type { HookExitCode, HookEventDisplayInfo } from './types.js';
import { t } from '../../../i18n/index.js';
/**
* Exit code descriptions for different hook types
*/
export function getHookExitCodes(eventName: string): HookExitCode[] {
const exitCodesMap: Record<string, HookExitCode[]> = {
[HookEventName.Stop]: [
{ code: 0, description: t('stdout/stderr not shown') },
{
code: 2,
description: t('show stderr to model and continue conversation'),
},
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.PreToolUse]: [
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 2, description: t('show stderr to model and block tool call') },
{
code: 'Other',
description: t('show stderr to user only but continue with tool call'),
},
],
[HookEventName.PostToolUse]: [
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
{ code: 2, description: t('show stderr to model immediately') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.PostToolUseFailure]: [
{ code: 0, description: t('stdout shown in transcript mode (ctrl+o)') },
{ code: 2, description: t('show stderr to model immediately') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.Notification]: [
{ code: 0, description: t('stdout/stderr not shown') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.UserPromptSubmit]: [
{ code: 0, description: t('stdout shown to Qwen') },
{
code: 2,
description: t(
'block processing, erase original prompt, and show stderr to user only',
),
},
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.SessionStart]: [
{ code: 0, description: t('stdout shown to Qwen') },
{
code: 'Other',
description: t('show stderr to user only (blocking errors ignored)'),
},
],
[HookEventName.SessionEnd]: [
{ code: 0, description: t('command completes successfully') },
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.SubagentStart]: [
{ code: 0, description: t('stdout shown to subagent') },
{
code: 'Other',
description: t('show stderr to user only (blocking errors ignored)'),
},
],
[HookEventName.SubagentStop]: [
{ code: 0, description: t('stdout/stderr not shown') },
{
code: 2,
description: t('show stderr to subagent and continue having it run'),
},
{ code: 'Other', description: t('show stderr to user only') },
],
[HookEventName.PreCompact]: [
{
code: 0,
description: t('stdout appended as custom compact instructions'),
},
{ code: 2, description: t('block compaction') },
{
code: 'Other',
description: t('show stderr to user only but continue with compaction'),
},
],
[HookEventName.PermissionRequest]: [
{ code: 0, description: t('use hook decision if provided') },
{ code: 'Other', description: t('show stderr to user only') },
],
};
return exitCodesMap[eventName] || [];
}
/**
* Short one-line description for hooks list view
*/
export function getHookShortDescription(eventName: string): string {
const descriptions: Record<string, string> = {
[HookEventName.PreToolUse]: t('Before tool execution'),
[HookEventName.PostToolUse]: t('After tool execution'),
[HookEventName.PostToolUseFailure]: t('After tool execution fails'),
[HookEventName.Notification]: t('When notifications are sent'),
[HookEventName.UserPromptSubmit]: t('When the user submits a prompt'),
[HookEventName.SessionStart]: t('When a new session is started'),
[HookEventName.Stop]: t('Right before Qwen Code concludes its response'),
[HookEventName.SubagentStart]: t(
'When a subagent (Agent tool call) is started',
),
[HookEventName.SubagentStop]: t(
'Right before a subagent concludes its response',
),
[HookEventName.PreCompact]: t('Before conversation compaction'),
[HookEventName.SessionEnd]: t('When a session is ending'),
[HookEventName.PermissionRequest]: t(
'When a permission dialog is displayed',
),
};
return descriptions[eventName] || '';
}
/**
* Detailed description for each hook event type (shown in detail view)
*/
export function getHookDescription(eventName: string): string {
const descriptions: Record<string, string> = {
[HookEventName.Stop]: '',
[HookEventName.PreToolUse]: t(
'Input to command is JSON of tool call arguments.',
),
[HookEventName.PostToolUse]: t(
'Input to command is JSON with fields "inputs" (tool call arguments) and "response" (tool call response).',
),
[HookEventName.PostToolUseFailure]: t(
'Input to command is JSON with tool_name, tool_input, tool_use_id, error, error_type, is_interrupt, and is_timeout.',
),
[HookEventName.Notification]: t(
'Input to command is JSON with notification message and type.',
),
[HookEventName.UserPromptSubmit]: t(
'Input to command is JSON with original user prompt text.',
),
[HookEventName.SessionStart]: t(
'Input to command is JSON with session start source.',
),
[HookEventName.SessionEnd]: t(
'Input to command is JSON with session end reason.',
),
[HookEventName.SubagentStart]: t(
'Input to command is JSON with agent_id and agent_type.',
),
[HookEventName.SubagentStop]: t(
'Input to command is JSON with agent_id, agent_type, and agent_transcript_path.',
),
[HookEventName.PreCompact]: t(
'Input to command is JSON with compaction details.',
),
[HookEventName.PermissionRequest]: t(
'Input to command is JSON with tool_name, tool_input, and tool_use_id. Output JSON with hookSpecificOutput containing decision to allow or deny.',
),
};
return descriptions[eventName] || '';
}
/**
* Source display mapping (translated)
*/
export function getTranslatedSourceDisplayMap(): Record<
HooksConfigSource,
string
> {
return {
[HooksConfigSource.Project]: t('Local Settings'),
[HooksConfigSource.User]: t('User Settings'),
[HooksConfigSource.System]: t('System Settings'),
[HooksConfigSource.Extensions]: t('Extensions'),
};
}
/**
* List of hook events to display in the UI
*/
export const DISPLAY_HOOK_EVENTS: HookEventName[] = [
HookEventName.Stop,
HookEventName.PreToolUse,
HookEventName.PostToolUse,
HookEventName.PostToolUseFailure,
HookEventName.Notification,
HookEventName.UserPromptSubmit,
HookEventName.SessionStart,
HookEventName.SessionEnd,
HookEventName.SubagentStart,
HookEventName.SubagentStop,
HookEventName.PreCompact,
HookEventName.PermissionRequest,
];
/**
* Create empty hook event display info
*/
export function createEmptyHookEventInfo(
eventName: HookEventName,
): HookEventDisplayInfo {
return {
event: eventName,
shortDescription: getHookShortDescription(eventName),
description: getHookDescription(eventName),
exitCodes: getHookExitCodes(eventName),
configs: [],
};
}

View file

@ -0,0 +1,11 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { HooksManagementDialog } from './HooksManagementDialog.js';
export { HooksListStep } from './HooksListStep.js';
export { HookDetailStep } from './HookDetailStep.js';
export * from './types.js';
export * from './constants.js';

View file

@ -0,0 +1,60 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
HookConfig,
HooksConfigSource,
HookEventName,
} from '@qwen-code/qwen-code-core';
/**
* Exit code description for hooks
*/
export interface HookExitCode {
code: number | string;
description: string;
}
/**
* UI display information for a hook event
*/
export interface HookEventDisplayInfo {
event: HookEventName;
shortDescription: string;
description: string;
exitCodes: HookExitCode[];
configs: HookConfigDisplayInfo[];
}
/**
* UI display information for a hook configuration
*/
export interface HookConfigDisplayInfo {
config: HookConfig;
source: HooksConfigSource;
sourceDisplay: string;
sourcePath?: string;
enabled: boolean;
}
/**
* Hook management dialog step names
*/
export const HOOKS_MANAGEMENT_STEPS = {
HOOKS_LIST: 'hooks_list',
HOOK_DETAIL: 'hook_detail',
HOOK_CONFIG_DETAIL: 'hook_config_detail',
} as const;
export type HooksManagementStep =
(typeof HOOKS_MANAGEMENT_STEPS)[keyof typeof HOOKS_MANAGEMENT_STEPS];
/**
* Props for HooksManagementDialog
*/
export interface HooksManagementDialogProps {
onClose: () => void;
}

View file

@ -17,7 +17,11 @@ import type {
Config,
EditorType,
} from '@qwen-code/qwen-code-core';
import { IdeClient, ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import {
IdeClient,
ToolConfirmationOutcome,
buildHumanReadableRuleLabel,
} from '@qwen-code/qwen-code-core';
import type { RadioSelectItem } from '../shared/RadioButtonSelect.js';
import { RadioButtonSelect } from '../shared/RadioButtonSelect.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
@ -243,16 +247,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = executionProps.permissionRules?.length
? ` [${executionProps.permissionRules.join(', ')}]`
const friendlyLabel = executionProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(executionProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
@ -324,18 +336,26 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel =
const friendlyLabel =
'permissionRules' in infoProps &&
(infoProps as { permissionRules?: string[] }).permissionRules?.length
? ` [${(infoProps as { permissionRules?: string[] }).permissionRules!.join(', ')}]`
? ` ${buildHumanReadableRuleLabel((infoProps as { permissionRules?: string[] }).permissionRules!)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});
@ -401,16 +421,24 @@ export const ToolConfirmationMessage: React.FC<
key: 'Yes, allow once',
});
if (isTrustedFolder && !confirmationDetails.hideAlwaysAllow) {
const rulesLabel = mcpProps.permissionRules?.length
? ` [${mcpProps.permissionRules.join(', ')}]`
const friendlyLabel = mcpProps.permissionRules?.length
? ` ${buildHumanReadableRuleLabel(mcpProps.permissionRules)}`
: '';
options.push({
label: t('Always allow in this project') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} in this project', {
action: friendlyLabel.trim(),
})
: t('Always allow in this project'),
value: ToolConfirmationOutcome.ProceedAlwaysProject,
key: 'Always allow in this project',
});
options.push({
label: t('Always allow for this user') + rulesLabel,
label: friendlyLabel
? t('Always allow {{action}} for this user', {
action: friendlyLabel.trim(),
})
: t('Always allow for this user'),
value: ToolConfirmationOutcome.ProceedAlwaysUser,
key: 'Always allow for this user',
});

View file

@ -83,6 +83,10 @@ export interface UIActions {
closeExtensionsManagerDialog: () => void;
// MCP dialog
closeMcpDialog: () => void;
// Hooks dialog
openHooksDialog: () => void;
// Hooks dialog
closeHooksDialog: () => void;
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;

View file

@ -136,6 +136,8 @@ export interface UIState {
isExtensionsManagerDialogOpen: boolean;
// MCP dialog
isMcpDialogOpen: boolean;
// Hooks dialog
isHooksDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
// Per-task token tracking

View file

@ -87,6 +87,7 @@ interface SlashCommandProcessorActions {
openAgentsManagerDialog: () => void;
openExtensionsManagerDialog: () => void;
openMcpDialog: () => void;
openHooksDialog: () => void;
}
/**
@ -523,6 +524,9 @@ export const useSlashCommandProcessor = (
case 'mcp':
actions.openMcpDialog();
return { type: 'handled' };
case 'hooks':
actions.openHooksDialog();
return { type: 'handled' };
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };

View file

@ -0,0 +1,31 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
export interface UseHooksDialogReturn {
isHooksDialogOpen: boolean;
openHooksDialog: () => void;
closeHooksDialog: () => void;
}
export const useHooksDialog = (): UseHooksDialogReturn => {
const [isHooksDialogOpen, setIsHooksDialogOpen] = useState(false);
const openHooksDialog = useCallback(() => {
setIsHooksDialogOpen(true);
}, []);
const closeHooksDialog = useCallback(() => {
setIsHooksDialogOpen(false);
}, []);
return {
isHooksDialogOpen,
openHooksDialog,
closeHooksDialog,
};
};