Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
733 changes: 733 additions & 0 deletions docs/users/configuration/settings.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/cli/src/commands/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export async function handleQwenAuth(
maxSessionTurns: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
channel: undefined,
systemPrompt: undefined,
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ export interface CliArgs {
maxSessionTurns: number | undefined;
coreTools: string[] | undefined;
excludeTools: string[] | undefined;
disabledSlashCommands: string[] | undefined;
authType: string | undefined;
channel: string | undefined;
}
Expand Down Expand Up @@ -507,6 +508,17 @@ export async function parseArguments(): Promise<CliArgs> {
coerce: (tools: string[]) =>
tools.flatMap((tool) => tool.split(',').map((t) => t.trim())),
})
.option('disabled-slash-commands', {
type: 'array',
string: true,
description:
'Slash command names to hide/disable (comma-separated or ' +
'repeated). Merged with the `slashCommands.disabled` setting ' +
'and QWEN_DISABLED_SLASH_COMMANDS. Matched case-insensitively ' +
'against the final command name.',
coerce: (names: string[]) =>
names.flatMap((n) => n.split(',').map((t) => t.trim())),
})
.option('auth-type', {
type: 'string',
choices: [
Expand Down Expand Up @@ -887,6 +899,29 @@ export async function loadCliConfig(
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
}

// Merge the slash-command denylist from settings + CLI flag + env var.
// Settings merge (UNION across scopes) is already handled upstream; we
// only de-duplicate while preserving case for diagnostic purposes.
const disabledSlashCommands: string[] = [];
const seenDisabled = new Set<string>();
const addDisabled = (value: string | undefined) => {
if (!value) return;
const trimmed = value.trim();
if (!trimmed) return;
const key = trimmed.toLowerCase();
if (!seenDisabled.has(key)) {
seenDisabled.add(key);
disabledSlashCommands.push(trimmed);
}
};
for (const name of settings.slashCommands?.disabled ?? []) addDisabled(name);
for (const name of argv.disabledSlashCommands ?? []) addDisabled(name);
for (const name of (process.env['QWEN_DISABLED_SLASH_COMMANDS'] ?? '').split(
',',
)) {
addDisabled(name);
}

// Helper: check if a tool is explicitly covered by an allow rule OR by the
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
// to preserve the original behaviour where "ShellTool", "Shell", and
Expand Down Expand Up @@ -1041,6 +1076,8 @@ export async function loadCliConfig(
coreTools: argv.coreTools || settings.tools?.core || undefined,
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
excludeTools: mergedDeny,
disabledSlashCommands:
disabledSlashCommands.length > 0 ? disabledSlashCommands : undefined,
// New unified permissions (PermissionManager source of truth).
permissions: {
allow: mergedAllow.length > 0 ? mergedAllow : undefined,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/config/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,34 @@ describe('Settings Loading and Merging', () => {
expect(settings.merged.advanced?.excludedEnvVars).toHaveLength(2);
});

it('should UNION-merge slashCommands.disabled across user and workspace scopes', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const userSettings = {
slashCommands: { disabled: ['auth', 'quit'] },
};
const workspaceSettings = {
// Workspace overlaps with user and adds one entry. UNION de-dupes the
// overlap and merges the new entry; it cannot remove user entries.
slashCommands: { disabled: ['quit', 'clear'] },
};

(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH) return JSON.stringify(userSettings);
if (p === MOCK_WORKSPACE_SETTINGS_PATH)
return JSON.stringify(workspaceSettings);
return '{}';
},
);

const settings = loadSettings(MOCK_WORKSPACE_DIR);
const disabled = settings.merged.slashCommands?.disabled ?? [];
expect(disabled).toEqual(
expect.arrayContaining(['auth', 'quit', 'clear']),
);
expect(disabled).toHaveLength(3);
});

it('should merge all settings files with the correct precedence', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
const systemDefaultsContent = {
Expand Down
30 changes: 30 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,6 +1016,36 @@ const SETTINGS_SCHEMA = {
},
},

slashCommands: {
type: 'object',
label: 'Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: {},
description:
'Configuration for slash commands exposed by the CLI. Useful for ' +
'locking down the command surface in multi-tenant or enterprise ' +
'deployments.',
showInDialog: false,
properties: {
disabled: {
type: 'array',
label: 'Disabled Slash Commands',
category: 'Advanced',
requiresRestart: true,
default: undefined as string[] | undefined,
description:
'Slash command names to hide and refuse to execute. Matched ' +
'case-insensitively against the final command name (for extension ' +
'commands this is the disambiguated form, e.g. "myext.deploy"). ' +
'Merged as a union across settings scopes, so workspace settings ' +
'can add to but not remove entries defined in system/user settings.',
showInDialog: false,
mergeStrategy: MergeStrategy.UNION,
},
},
},

tools: {
type: 'object',
label: 'Tools',
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/src/core/theme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2025 Qwen Code
* SPDX-License-Identifier: Apache-2.0
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { validateTheme } from './theme.js';

const mockFindThemeByName = vi.fn();
vi.mock('../ui/themes/theme-manager.js', () => ({
themeManager: {
findThemeByName: (...args: unknown[]) => mockFindThemeByName(...args),
},
AUTO_THEME_NAME: 'auto',
}));

vi.mock('../i18n/index.js', () => ({
t: (msg: string, params?: Record<string, string>) => {
if (params) {
return msg.replace(
/\{\{(\w+)\}\}/g,
(_, key) => params[key] ?? `{{${key}}}`,
);
}
return msg;
},
}));

describe('validateTheme', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('should return null when no theme is configured', () => {
const settings = { merged: { ui: {} } };
const result = validateTheme(settings as never);
expect(result).toBeNull();
});

it('should return null when theme is found', () => {
mockFindThemeByName.mockReturnValue({ name: 'dark' });
const settings = { merged: { ui: { theme: 'dark' } } };

const result = validateTheme(settings as never);

expect(result).toBeNull();
expect(mockFindThemeByName).toHaveBeenCalledWith('dark');
});

it('should return error message when theme is not found', () => {
mockFindThemeByName.mockReturnValue(undefined);
const settings = { merged: { ui: { theme: 'nonexistent-theme' } } };

const result = validateTheme(settings as never);

expect(result).toBe('Theme "nonexistent-theme" not found.');
});

it('should return null when ui section is undefined', () => {
const settings = { merged: {} };
const result = validateTheme(settings as never);
expect(result).toBeNull();
});

it('should return null when theme is set to auto', () => {
const settings = { merged: { ui: { theme: 'auto' } } };
const result = validateTheme(settings as never);
expect(result).toBeNull();
expect(mockFindThemeByName).not.toHaveBeenCalled();
});
});
8 changes: 6 additions & 2 deletions packages/cli/src/core/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { themeManager } from '../ui/themes/theme-manager.js';
import { themeManager, AUTO_THEME_NAME } from '../ui/themes/theme-manager.js';
import { type LoadedSettings } from '../config/settings.js';
import { t } from '../i18n/index.js';

Expand All @@ -15,7 +15,11 @@ import { t } from '../i18n/index.js';
*/
export function validateTheme(settings: LoadedSettings): string | null {
const effectiveTheme = settings.merged.ui?.theme;
if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) {
if (
effectiveTheme &&
effectiveTheme !== AUTO_THEME_NAME &&
!themeManager.findThemeByName(effectiveTheme)
) {
return t('Theme "{{themeName}}" not found.', {
themeName: effectiveTheme,
});
Expand Down
21 changes: 9 additions & 12 deletions packages/cli/src/gemini.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -277,18 +277,16 @@ describe('gemini.tsx main function', () => {
throw new MockProcessExitError(code);
});

const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
const cleanupModule = await import('./utils/cleanup.js');
const validatorModule = await import('./validateNonInterActiveAuth.js');
const streamJsonModule = await import('./nonInteractive/session.js');
const initializerModule = await import('./core/initializer.js');
const startupWarningsModule = await import('./utils/startupWarnings.js');
const userStartupWarningsModule = await import(
'./utils/userStartupWarnings.js'
);
const userStartupWarningsModule =
await import('./utils/userStartupWarnings.js');

vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined);
vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {});
Expand Down Expand Up @@ -427,12 +425,10 @@ describe('gemini.tsx main function kitty protocol', () => {
});

it('should call setRawMode and detectAndEnableKittyProtocol when isInteractive is true', async () => {
const { detectAndEnableKittyProtocol } = await import(
'./ui/utils/kittyProtocolDetector.js'
);
const { loadCliConfig, parseArguments } = await import(
'./config/config.js'
);
const { detectAndEnableKittyProtocol } =
await import('./ui/utils/kittyProtocolDetector.js');
const { loadCliConfig, parseArguments } =
await import('./config/config.js');
const { loadSettings } = await import('./config/settings.js');
vi.mocked(loadCliConfig).mockResolvedValue({
isInteractive: () => true,
Expand Down Expand Up @@ -503,6 +499,7 @@ describe('gemini.tsx main function kitty protocol', () => {
resume: undefined,
coreTools: undefined,
excludeTools: undefined,
disabledSlashCommands: undefined,
authType: undefined,
maxSessionTurns: undefined,
experimentalLsp: undefined,
Expand Down
43 changes: 37 additions & 6 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import { SettingsContext } from './ui/contexts/SettingsContext.js';
import { VimModeProvider } from './ui/contexts/VimModeContext.js';
import { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
import { checkForUpdates } from './ui/utils/updateCheck.js';
import {
Expand Down Expand Up @@ -259,14 +259,21 @@ export async function main() {
// Load custom themes from settings
themeManager.loadCustomThemes(settings.merged.ui?.customThemes);

if (settings.merged.ui?.theme) {
if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) {
const configuredTheme = settings.merged.ui?.theme;
if (configuredTheme && configuredTheme !== AUTO_THEME_NAME) {
if (!themeManager.setActiveTheme(configuredTheme)) {
// If the theme is not found during initial load, log a warning and continue.
// The useThemeCommand hook in AppContainer.tsx will handle opening the dialog.
writeStderrLine(
`Warning: Theme "${settings.merged.ui?.theme}" not found.`,
);
writeStderrLine(`Warning: Theme "${configuredTheme}" not found.`);
}
} else {
// 'auto' or unset: resolve a synchronous baseline (COLORFGBG + macOS)
// so non-interactive runs and any pre-render UI (e.g. the --resume
// session picker) already have a sensible theme. The interactive
// startup block refines this with an OSC 11 probe later on, which is
// intentionally deferred to run inside the early-capture window so
// terminal response bytes cannot leak into the TUI input.
themeManager.setActiveTheme(AUTO_THEME_NAME);
}

// hop into sandbox if we are outside and sandboxing is enabled
Expand Down Expand Up @@ -403,6 +410,7 @@ export async function main() {

const wasRaw = process.stdin.isRaw;
let kittyProtocolDetectionComplete: Promise<boolean> | undefined;
let themeAutoDetectionComplete: Promise<void> | undefined;
if (config.isInteractive() && !wasRaw && process.stdin.isTTY) {
// Set this as early as possible to avoid spurious characters from
// input showing up in the output.
Expand All @@ -418,6 +426,24 @@ export async function main() {

// Detect and enable Kitty keyboard protocol once at startup.
kittyProtocolDetectionComplete = detectAndEnableKittyProtocol();

// Auto-detect theme (OSC 11 + COLORFGBG + macOS) when the user has
// opted into 'auto' or has not configured a theme at all. Kicked off
// here without awaiting so the OSC 11 timeout overlaps with the
// heavier startup work below (initializeApp, warnings) instead of
// blocking the critical path. The synchronous baseline picked above
// keeps the active theme valid in the meantime; this probe only
// refines it. Running inside the early-capture window is deliberate:
// the filter in startEarlyInputCapture absorbs the OSC 11 response
// bytes so they cannot leak into the TUI input, even though our
// probe attaches its own listener to parse the RGB value.
if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) {
themeAutoDetectionComplete = themeManager
.resolveAutoThemeAsync()
.catch((err) => {
debugLogger.warn('Async theme auto-detection failed:', err);
});
}
}

setMaxSizedBoxDebugging(isDebugMode);
Expand Down Expand Up @@ -456,6 +482,11 @@ export async function main() {
if (config.isInteractive()) {
// Need kitty detection to be complete before we can start the interactive UI.
await kittyProtocolDetectionComplete;
// Drain the auto-theme probe before render so the OSC 11 response is
// absorbed by the early-capture filter (which is closed inside
// startInteractiveUI) and so the first paint uses the refined theme
// when the probe finishes in time.
await themeAutoDetectionComplete;
await startInteractiveUI(
config,
settings,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,8 @@ export default {
'Tool Schema Compliance': 'Werkzeug-Schema-Konformität',
// Settings enum options
'Auto (detect from system)': 'Automatisch (vom System erkennen)',
'Auto (detect terminal theme)': 'Automatisch (Terminal-Theme erkennen)',
Auto: 'Automatisch',
Text: 'Text',
JSON: 'JSON',
Plan: 'Plan',
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ export default {
'Tool Schema Compliance': 'Tool Schema Compliance',
// Settings enum options
'Auto (detect from system)': 'Auto (detect from system)',
'Auto (detect terminal theme)': 'Auto (detect terminal theme)',
Auto: 'Auto',
Text: 'Text',
JSON: 'JSON',
Plan: 'Plan',
Expand Down
Loading
Loading