From 06cd7ce13f83ae86ff897a978335f035c9ef40a3 Mon Sep 17 00:00:00 2001 From: AlexHuang Date: Tue, 7 Jul 2026 12:49:36 +0800 Subject: [PATCH] feat(cli): add --project and --global flags to /model for per-project model persistence (#6060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add --project and --global flags to /model for per-project model persistence Add scope control to the /model command so users can persist model selections to either project-level or user-level settings independently. - /model --project: persist to workspace .qwen/settings.json - /model --global: persist to user ~/.qwen/settings.json - /model (no flag): unchanged behavior (backward compatible) - Model dialog title shows scope: 'Select Model (this project)' / 'Select Model (global)' - Completion and argumentHint updated with new flags - Full i18n support for zh/en Closes #6052 Signed-off-by: Alex * fix(cli): add missing zh-TW translations for /model scope flags Signed-off-by: Alex * fix(cli): address PR review — scope flags, subcommand persistScope, titles, tests - parseScopeFlags: use (?:^|\s) instead of \b for --flag matching (\b fails because - is not a word character) - Completion: strip all flags to isolate model prefix, supports any order - Subcommand dialogs (fast/voice/vision) now propagate persistScope - slashCommandProcessor forwards persistScope for all subcommand cases - ModelDialog title combines subcommand mode + scope label e.g. 'Select Fast Model (this project)' - Subcommand confirmations show scope suffix (project/global) - Extract persistScopeSpread() helper to reduce duplication - Add 9 tests covering scope flags, dialog returns, confirmations - Add i18n keys for scope suffix labels in zh/en/zh-TW Signed-off-by: Alex * fix(cli): use Partial & {[key:string]:unknown} to fix index signature TS error Replace Record with Partial & {[key:string]:unknown} to satisfy TS4111 index signature access rule in the CI build. Signed-off-by: Alex * fix(cli): add scope suffix to ModelDialog history items Address review comment: historyManager.addItem for voice/fast/vision/main model selections now shows scope indicator like ' (this project)' or ' (global)', consistent with CLI direct-set confirmations. Affected: handleModelSwitchSuccess (main), handleSelect (voice/fast/vision) Signed-off-by: Alex * fix(cli): wrap scopeSuffix in t() and unify wording with ModelDialog - scopeSuffix in modelCommand.ts now uses t(' (this project)') / t(' (global)') instead of hardcoded English strings, matching ModelDialog.tsx wording - Main model confirmation uses shared scopeSuffix instead of separate i18n keys, eliminating 'Model: {{model}} (project)' duplication - Remove unused i18n keys from en/zh/zh-TW locales - Update tests to expect '(this project)' wording Signed-off-by: Alex * fix(cli): address code review feedback — scope validation, i18n, tests - Reject inline prompt + scope flag combination with clear error (#1) - Add mutual exclusivity check for --project and --global (#5) - Verify setValue scope parameter in tests + add --global test (#2) - Extract scopeSuffix to shared variable, remove duplication (#3) - Remove dead i18n keys 'Select Model (this project)' / '(global)' (#4) - Fix scopeSuffix placement on model line not API key line (#8) - Add fr.js / ja.js translations for scope keys (#10) - Remove unused export ModelDialogPersistScope (#6) - Wrap non-interactive help text in t() with new flags (#7) - Fix argumentHint grouping to show mode vs scope flags (#11) Signed-off-by: Alex * fix(cli): reject --project when workspace is untrusted Reject --project scope flag before direct persistence or opening ModelDialog when settings.isTrusted is false. Workspace settings are ignored on merge in that state, so the save would silently not take effect. Also mirrors the guard in ModelDialog.tsx resolvePersistScope() to fall back to user scope when the dialog is opened with --project on an untrusted folder. Default mock settings now includes isTrusted: true. Signed-off-by: Alex --------- Signed-off-by: Alex Co-authored-by: qwen-code-dev-bot --- .../plans/2026-06-30-channel-loop.md | 77 ++++--- .../src/background/service-worker.ts | 4 +- packages/cli/src/i18n/locales/en.js | 10 +- packages/cli/src/i18n/locales/fr.js | 6 + packages/cli/src/i18n/locales/ja.js | 6 + packages/cli/src/i18n/locales/zh-TW.js | 10 +- packages/cli/src/i18n/locales/zh.js | 10 +- .../cli/src/test-utils/mockCommandContext.ts | 1 + packages/cli/src/ui/AppContainer.tsx | 3 + .../cli/src/ui/commands/modelCommand.test.ts | 210 +++++++++++++++++- packages/cli/src/ui/commands/modelCommand.ts | 187 +++++++++++++--- packages/cli/src/ui/commands/types.ts | 6 + .../cli/src/ui/components/DialogManager.tsx | 1 + .../cli/src/ui/components/ModelDialog.tsx | 83 +++++-- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 20 +- packages/cli/src/ui/hooks/useModelCommand.ts | 11 + .../core/src/tools/mcp-transport-pool.test.ts | 20 +- 18 files changed, 576 insertions(+), 90 deletions(-) diff --git a/docs/superpowers/plans/2026-06-30-channel-loop.md b/docs/superpowers/plans/2026-06-30-channel-loop.md index 40a0240bbe..739f3c8148 100644 --- a/docs/superpowers/plans/2026-06-30-channel-loop.md +++ b/docs/superpowers/plans/2026-06-30-channel-loop.md @@ -29,6 +29,7 @@ ## Task 1: Core Exports And Store **Files:** + - Modify: `packages/core/src/index.ts` - Modify: `packages/channels/base/src/paths.ts` - Create: `packages/channels/base/src/ChannelLoopStore.ts` @@ -80,7 +81,9 @@ describe('ChannelLoopStore', () => { runCount: 0, createdAt: '2026-06-30T09:00:00.000Z', }); - await expect(store.listForTarget('telegram-main', target)).resolves.toHaveLength(1); + await expect( + store.listForTarget('telegram-main', target), + ).resolves.toHaveLength(1); }); it('enforces target quotas atomically through createForTarget', async () => { @@ -100,32 +103,37 @@ describe('ChannelLoopStore', () => { createdBy: 'Alice', }; - await expect(store.createForTarget(input, 1)).resolves.toMatchObject({ id: 'loop-1' }); + await expect(store.createForTarget(input, 1)).resolves.toMatchObject({ + id: 'loop-1', + }); await expect(store.createForTarget(input, 1)).resolves.toBeUndefined(); }); it('loads pre-lifecycle loop JSON with runCount defaulted to 0', async () => { const dir = await mkdtemp(join(tmpdir(), 'channel-loop-legacy-')); const filePath = join(dir, 'loops.json'); - await writeFile(filePath, JSON.stringify([ - { - id: 'loop-legacy', - channelName: 'telegram-main', - target, - cwd: '/repo', - cron: '0 9 * * *', - prompt: 'post summary', - recurring: true, - enabled: true, - createdBy: 'Alice', - createdAt: '2026-06-30T09:00:00.000Z', - consecutiveFailures: 0, - }, - ])); + await writeFile( + filePath, + JSON.stringify([ + { + id: 'loop-legacy', + channelName: 'telegram-main', + target, + cwd: '/repo', + cron: '0 9 * * *', + prompt: 'post summary', + recurring: true, + enabled: true, + createdBy: 'Alice', + createdAt: '2026-06-30T09:00:00.000Z', + consecutiveFailures: 0, + }, + ]), + ); - await expect(new ChannelLoopStore({ filePath }).list()).resolves.toMatchObject([ - { id: 'loop-legacy', runCount: 0 }, - ]); + await expect( + new ChannelLoopStore({ filePath }).list(), + ).resolves.toMatchObject([{ id: 'loop-legacy', runCount: 0 }]); }); it('refuses corrupt JSON instead of treating it as empty state', async () => { @@ -334,7 +342,9 @@ export class ChannelLoopStore { } for (const [index, value] of parsed.entries()) { if (!isChannelLoop(value)) { - throw new Error(`Invalid channel loop at index ${index} in ${this.filePath}.`); + throw new Error( + `Invalid channel loop at index ${index} in ${this.filePath}.`, + ); } } return parsed.map(normalizeLoop); @@ -401,7 +411,8 @@ function isChannelLoop(value: unknown): value is ChannelLoop { (loop['lastStatus'] === undefined || loop['lastStatus'] === 'ok' || loop['lastStatus'] === 'error') && - (loop['lastError'] === undefined || typeof loop['lastError'] === 'string') && + (loop['lastError'] === undefined || + typeof loop['lastError'] === 'string') && typeof loop['consecutiveFailures'] === 'number' && (loop['runningSince'] === undefined || typeof loop['runningSince'] === 'string') && @@ -430,7 +441,11 @@ Export from `index.ts` and export cron helpers from `packages/core/src/index.ts` ```ts export { ChannelLoopStore } from './ChannelLoopStore.js'; -export type { ChannelLoop, ChannelLoopInput, ChannelLoopPatch } from './ChannelLoopStore.js'; +export type { + ChannelLoop, + ChannelLoopInput, + ChannelLoopPatch, +} from './ChannelLoopStore.js'; export { channelLoopPath } from './paths.js'; export { nextFireTime, parseCron } from './utils/cronParser.js'; ``` @@ -448,6 +463,7 @@ Expected: all tests pass. ## Task 2: Channel Loop Scheduler **Files:** + - Create: `packages/channels/base/src/ChannelLoopScheduler.ts` - Create: `packages/channels/base/src/ChannelLoopScheduler.test.ts` - Modify: `packages/channels/base/src/index.ts` @@ -509,6 +525,7 @@ Expected: all tests pass. ## Task 3: ChannelBase `/loop` Command Surface **Files:** + - Modify: `packages/channels/base/src/ChannelBase.ts` - Modify: `packages/channels/base/src/ChannelBase.test.ts` @@ -541,8 +558,14 @@ Add: ```ts export interface ChannelLoopController { create(input: ChannelLoopInput): Promise; - createForTarget?(input: ChannelLoopInput, maxEnabledLoops: number): Promise; - listForTarget(channelName: string, target: SessionTarget): Promise; + createForTarget?( + input: ChannelLoopInput, + maxEnabledLoops: number, + ): Promise; + listForTarget( + channelName: string, + target: SessionTarget, + ): Promise; disable(id: string): Promise; validateCron(cron: string): void; nextFireTime?(loop: ChannelLoop): Date; @@ -611,6 +634,7 @@ Expected: all ChannelBase tests pass. ## Task 4: Loop Prompt Execution **Files:** + - Modify: `packages/channels/base/src/ChannelBase.ts` - Modify: `packages/channels/base/src/ChannelBase.test.ts` @@ -671,6 +695,7 @@ Expected: all ChannelBase tests pass. ## Task 5: Adapter Opt-In **Files:** + - Modify: `packages/channels/telegram/src/TelegramAdapter.ts` - Modify: `packages/channels/telegram/src/TelegramAdapter.test.ts` - Modify: `packages/channels/feishu/src/FeishuAdapter.ts` @@ -715,6 +740,7 @@ Expected: all adapter tests pass. ## Task 6: CLI Startup Wiring **Files:** + - Modify: `packages/cli/src/commands/channel/start.ts` - Modify: `packages/cli/src/commands/channel/start.test.ts` @@ -787,6 +813,7 @@ Expected: all CLI start tests pass. ## Task 7: Verification And PR **Files:** + - Modify: `.qwen/pr-drafts/channel-loop.md` - [ ] **Step 1: Run focused verification** diff --git a/packages/chrome-extension/src/background/service-worker.ts b/packages/chrome-extension/src/background/service-worker.ts index 3f2aef1af8..d34bceddb5 100644 --- a/packages/chrome-extension/src/background/service-worker.ts +++ b/packages/chrome-extension/src/background/service-worker.ts @@ -123,9 +123,7 @@ function onWsMessage(ws: WebSocket, data: unknown): void { // CDP-tunnel frames: route to the bridge, which drives the tab via // chrome.debugger and pushes results/events back over the active socket. if (isCdpBridgeFrame(msg['type'])) { - handleCdpFrame(msg as { type?: unknown }, (frame) => - sendRaw(ws, frame), - ); + handleCdpFrame(msg as { type?: unknown }, (frame) => sendRaw(ws, frame)); return; } // Other frame types (chat/session traffic) aren't ours; ignore. diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index c6f6b88176..61ede7a27d 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1436,8 +1436,8 @@ export default { 'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).', 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).': 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).', - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', "Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.": "Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.", "Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.": @@ -1451,6 +1451,10 @@ export default { 'Set the model for voice transcription', 'Set the image-capable model used to transcribe images for a text-only main model': 'Set the image-capable model used to transcribe images for a text-only main model', + 'Persist the model selection to the project settings (workspace scope)': + 'Persist the model selection to the project settings (workspace scope)', + 'Persist the model selection to the user settings (global scope)': + 'Persist the model selection to the user settings (global scope)', 'Select Fast Model': 'Select Fast Model', 'Select Vision Model': 'Select Vision Model', 'Select Voice Model': 'Select Voice Model', @@ -1687,6 +1691,8 @@ export default { // Dialogs - Model // ============================================================================ 'Select Model': 'Select Model', + ' (this project)': ' (this project)', + ' (global)': ' (global)', 'API Key': 'API Key', '(default)': '(default)', '(not set)': '(not set)', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index d6c5601ee3..9306775b8d 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -1288,6 +1288,12 @@ export default { // Boîtes de dialogue - Modèle // ============================================================================ 'Select Model': 'Sélectionner un modèle', + ' (this project)': ' (ce projet)', + ' (global)': ' (global)', + 'Persist the model selection to the project settings (workspace scope)': + 'Persister la sélection du modèle dans les paramètres du projet (étendue workspace)', + 'Persist the model selection to the user settings (global scope)': + 'Persister la sélection du modèle dans les paramètres utilisateur (étendue globale)', 'API Key': 'API Key', '(default)': '(par défaut)', '(not set)': '(non défini)', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 7f8d3a8d85..6e9e9b6cab 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1741,6 +1741,12 @@ export default { 'ANTHROPIC_BASE_URL environment variable not found.': '環境変数 ANTHROPIC_BASE_URL が見つかりません。', 'Invalid auth method selected.': '無効な認証方式が選択されました。', + ' (this project)': ' (このプロジェクト)', + ' (global)': ' (グローバル)', + 'Persist the model selection to the project settings (workspace scope)': + 'モデルの選択をプロジェクト設定に永続化(ワークスペーススコープ)', + 'Persist the model selection to the user settings (global scope)': + 'モデルの選択をユーザー設定に永続化(グローバルスコープ)', 'API Key': 'API Key', '(default)': '(デフォルト)', '(not set)': '(未設定)', diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 8b5fb4a05f..f59a257e29 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -1262,8 +1262,8 @@ export default { '切換此會話的模型(--fast 可設置建議模型)', 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).': '切換此會話的模型(--fast 可設置建議模型,--voice 可設置語音轉寫模型,[model-id] 可立即切換)', - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': - '切換此會話的模型(--fast 可設置建議模型,--voice 可設置語音轉寫模型,--vision 可設置視覺橋接模型,[model-id] 可立即切換,或用 [model-id] [prompt] 在另一個模型上執行一次性提示;內聯提示按原文發送,不展開 @file)', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': + '切換此會話的模型(--fast 建議模型,--voice 語音轉寫模型,--vision 視覺橋接模型,--project 持久化到專案設定,--global 持久化到使用者設定,[model-id] 立即切換,或用 [model-id] [prompt] 在另一個模型上執行一次性提示;內聯提示按原文發送,不展開 @file)', "Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.": "此模式不支援內聯一次性覆寫——請先執行 '/model {{model}}',再發送你的提示。", "Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.": @@ -1276,6 +1276,10 @@ export default { 'Set the model for voice transcription': '設定語音轉寫模型', 'Set the image-capable model used to transcribe images for a text-only main model': '設定用於為純文字主模型轉寫圖像的圖像能力模型', + 'Persist the model selection to the project settings (workspace scope)': + '將模型選擇持久化到專案設定(工作區)', + 'Persist the model selection to the user settings (global scope)': + '將模型選擇持久化到使用者設定(全域)', 'Select Fast Model': '選擇快速模型', 'Select Vision Model': '選擇視覺模型', 'Select Voice Model': '選擇語音模型', @@ -1469,6 +1473,8 @@ export default { 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': '無效的 QWEN_DEFAULT_AUTH_TYPE 值:"{{value}}"。有效值為:{{validValues}}', 'Select Model': '選擇模型', + ' (this project)': '(當前專案)', + ' (global)': '(全域)', 'API Key': 'API Key', '(default)': '(默認)', '(not set)': '(未設置)', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index cf038b4061..8fca65b984 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1376,8 +1376,8 @@ export default { '切换此会话的模型(--fast 可设置建议模型)', 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).': '切换此会话的模型(--fast 可设置建议模型,--voice 可设置语音转写模型,[model-id] 可立即切换)', - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': - '切换此会话的模型(--fast 可设置建议模型,--voice 可设置语音转写模型,--vision 可设置视觉桥接模型,[model-id] 可立即切换,或用 [model-id] [prompt] 在另一个模型上运行一次性提示;内联提示按原文发送,不展开 @file)', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).': + '切换此会话的模型(--fast 建议模型,--voice 语音转写模型,--vision 视觉桥接模型,--project 持久化到项目设置,--global 持久化到用户设置,[model-id] 立即切换,或用 [model-id] [prompt] 在另一个模型上运行一次性提示;内联提示按原文发送,不展开 @file)', "Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.": "此模式不支持内联一次性覆盖——请先运行 '/model {{model}}',再发送你的提示。", "Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.": @@ -1390,6 +1390,10 @@ export default { 'Set the model for voice transcription': '设置语音转写模型', 'Set the image-capable model used to transcribe images for a text-only main model': '设置用于为纯文本主模型转写图像的图像能力模型', + 'Persist the model selection to the project settings (workspace scope)': + '将模型选择持久化到项目设置(工作区)', + 'Persist the model selection to the user settings (global scope)': + '将模型选择持久化到用户设置(全局)', 'Select Fast Model': '选择快速模型', 'Select Vision Model': '选择视觉模型', 'Select Voice Model': '选择语音模型', @@ -1611,6 +1615,8 @@ export default { // Dialogs - Model // ============================================================================ 'Select Model': '选择模型', + ' (this project)': '(当前项目)', + ' (global)': '(全局)', 'API Key': 'API Key', '(default)': '(默认)', '(not set)': '(未设置)', diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 482e828f12..4d0a16b23a 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -39,6 +39,7 @@ export const createMockCommandContext = ( settings: { merged: {}, setValue: vi.fn(), + isTrusted: true, } as unknown as LoadedSettings, logger: { log: vi.fn(), diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 4aa6bd8312..a0d2c3e77e 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1158,6 +1158,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + modelDialogPersistScope, openModelDialog, closeModelDialog, } = useModelCommand(); @@ -3747,6 +3748,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + modelDialogPersistScope, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, @@ -3887,6 +3889,7 @@ export const AppContainer = (props: AppContainerProps) => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + modelDialogPersistScope, isTrustDialogOpen, activeArenaDialog, isPermissionsDialogOpen, diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index 446c845a57..93ae32fabe 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { modelCommand } from './modelCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { SettingScope } from '../../config/settings.js'; import { AuthType, type ContentGeneratorConfig, @@ -45,7 +46,7 @@ describe('modelCommand', () => { it('should have the correct name and description', () => { expect(modelCommand.name).toBe('model'); expect(modelCommand.description).toBe( - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', ); }); @@ -2022,4 +2023,211 @@ describe('modelCommand', () => { }); }); }); + + describe('scope flags', () => { + function setupContext() { + const mockConfig = createMockConfig({ + model: 'test-model', + authType: AuthType.USE_OPENAI, + }); + ( + mockConfig as Partial & { [key: string]: unknown } + ).getAvailableModelsForAuthType = vi.fn().mockReturnValue([]); + ( + mockConfig as Partial & { [key: string]: unknown } + ).getAllConfiguredModels = vi.fn().mockReturnValue([]); + mockContext.services.config = mockConfig as Config; + return mockContext; + } + + it('should include persistScope in dialog return for /model --project', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--project'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'model', + persistScope: 'workspace', + }); + }); + + it('should include persistScope in dialog return for /model --global', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--global'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'model', + persistScope: 'user', + }); + }); + + it('should include persistScope for /model --project --fast dialog', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--project --fast'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'fast-model', + persistScope: 'workspace', + }); + }); + + it('should include persistScope for /model --global --voice dialog', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--global --voice'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'voice-model', + persistScope: 'user', + }); + }); + + it('should include persistScope for /model --project --vision dialog', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--project --vision'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'vision-model', + persistScope: 'workspace', + }); + }); + + it('should parse scope flags in any position', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, '--fast --project'); + expect(result).toEqual({ + type: 'dialog', + dialog: 'fast-model', + persistScope: 'workspace', + }); + }); + + it('should show scope suffix in fast model confirmation', async () => { + const setValue = vi.fn(); + const settings = { + ...createMockSettings(setValue), + _merged: {}, + computeMergedSettings: vi.fn(), + isTrusted: true, + } as unknown as LoadedSettings; + const ctx = setupContext(); + ctx.services.settings = settings; + const cfg = ctx.services.config as unknown as Partial & { + [key: string]: unknown; + }; + cfg.getAllConfiguredModels = vi + .fn() + .mockReturnValue([ + { id: 'qwen3-coder-flash', voiceOnly: false, fastOnly: true }, + ]); + cfg.setFastModel = vi.fn(); + const result = await modelCommand.action!( + ctx, + '--project --fast qwen3-coder-flash', + ); + expect(result).toMatchObject({ + type: 'message', + content: expect.stringContaining('(this project)'), + }); + expect(setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'fastModel', + 'qwen3-coder-flash', + ); + }); + + it('should persist to global scope with --global', async () => { + const setValue = vi.fn(); + const settings = { + ...createMockSettings(setValue), + _merged: {}, + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; + const ctx = setupContext(); + ctx.services.settings = settings; + const cfg = ctx.services.config as unknown as Partial & { + [key: string]: unknown; + }; + cfg.getAllConfiguredModels = vi + .fn() + .mockReturnValue([ + { id: 'qwen3-coder-flash', voiceOnly: false, fastOnly: true }, + ]); + cfg.setFastModel = vi.fn(); + const result = await modelCommand.action!( + ctx, + '--global --fast qwen3-coder-flash', + ); + expect(result).toMatchObject({ + type: 'message', + content: expect.stringContaining('(global)'), + }); + expect(setValue).toHaveBeenCalledWith( + SettingScope.User, + 'fastModel', + 'qwen3-coder-flash', + ); + }); + + it('should reject --project when workspace is untrusted', async () => { + const setValue = vi.fn(); + const settings = { + ...createMockSettings(setValue), + _merged: {}, + computeMergedSettings: vi.fn(), + isTrusted: false, + } as unknown as LoadedSettings; + const ctx = setupContext(); + ctx.services.settings = settings; + const result = await modelCommand.action!(ctx, '--project qwen-max'); + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + content: expect.stringContaining('untrusted'), + }); + expect(setValue).not.toHaveBeenCalled(); + }); + + it('should show scope suffix in main model confirmation', async () => { + const setValue = vi.fn(); + const settings = { + ...createMockSettings(setValue), + _merged: {}, + computeMergedSettings: vi.fn(), + isTrusted: true, + } as unknown as LoadedSettings; + const mockGenerator = { + authType: AuthType.USE_OPENAI, + model: 'qwen-max', + }; + const ctx = setupContext(); + ctx.services.settings = settings; + const cfg = ctx.services.config as unknown as Partial & { + [key: string]: unknown; + }; + cfg.getAvailableModelsForAuthType = vi + .fn() + .mockReturnValue([ + { id: 'qwen-max', voiceOnly: false, fastOnly: false }, + ]); + cfg.switchModel = vi.fn().mockResolvedValue(mockGenerator); + const result = await modelCommand.action!(ctx, '--project qwen-max'); + expect(result).toMatchObject({ + type: 'message', + content: expect.stringContaining('(this project)'), + }); + expect(setValue).toHaveBeenCalledWith( + SettingScope.Workspace, + 'model.name', + 'qwen-max', + ); + }); + + it('should not include persistScope when no scope flag is given', async () => { + const ctx = setupContext(); + const result = await modelCommand.action!(ctx, ''); + expect(result).toEqual({ + type: 'dialog', + dialog: 'model', + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index 94fc7f870c..29d9f94da3 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -22,7 +22,7 @@ import { parseVisionModelSetting, resolveModelId, } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; +import { SettingScope, type LoadedSettings } from '../../config/settings.js'; import { isInlineModelOverrideAllowed, parseAcpModelOption, @@ -41,6 +41,50 @@ const FAST_MODEL_CONFIGURATION_HINT = const VISION_MODEL_CONFIGURATION_HINT = 'Configure an image-capable model in settings.modelProviders and ensure the required environment variables are set. Run /model --vision to set it, or leave it unset to auto-pick a same-provider vision model.'; +/** + * Parse --project / --global scope flags from the argument string. + * Returns the resolved scope override and the remaining args with flags stripped. + */ +function parseScopeFlags(args: string): { + scopeOverride: SettingScope | undefined; + remaining: string; + hasProject: boolean; + hasGlobal: boolean; +} { + let scopeOverride: SettingScope | undefined; + let remaining = args; + const hasProject = /(?:^|\s)--project(?:\s|$)/.test(remaining); + const hasGlobal = /(?:^|\s)--global(?:\s|$)/.test(remaining); + + if (hasProject) { + scopeOverride = SettingScope.Workspace; + remaining = remaining.replace(/(?:^|\s)--project(?:\s|$)/, ' ').trim(); + } else if (hasGlobal) { + scopeOverride = SettingScope.User; + remaining = remaining.replace(/(?:^|\s)--global(?:\s|$)/, ' ').trim(); + } + + return { scopeOverride, remaining, hasProject, hasGlobal }; +} + +function resolveScope( + settings: LoadedSettings, + scopeOverride: SettingScope | undefined, +): SettingScope { + return scopeOverride ?? getPersistScopeForModelSelection(settings); +} + +function persistScopeSpread( + scopeOverride: SettingScope | undefined, + // eslint-disable-next-line @typescript-eslint/no-empty-object-type +): { persistScope: 'workspace' } | { persistScope: 'user' } | {} { + if (scopeOverride === SettingScope.Workspace) + return { persistScope: 'workspace' as const }; + if (scopeOverride === SettingScope.User) + return { persistScope: 'user' as const }; + return {}; +} + function formatVisionModelSettingForDisplay(setting: string): string { const parsed = parseVisionModelSetting(setting); if (!parsed) return setting.replace(/\0/g, '\\0'); @@ -53,8 +97,9 @@ function persistSetting( settings: LoadedSettings, path: string, value: unknown, + scopeOverride?: SettingScope, ): void { - settings.setValue(getPersistScopeForModelSelection(settings), path, value); + settings.setValue(resolveScope(settings, scopeOverride), path, value); } async function switchMainModel( @@ -62,6 +107,7 @@ async function switchMainModel( settings: LoadedSettings, currentAuthType: AuthType, modelArg: string, + scopeOverride?: SettingScope, ): Promise { const parsed = parseAcpModelOption(modelArg); @@ -74,20 +120,25 @@ async function switchMainModel( ? { requireCachedCredentials: true } : undefined, ); - persistSetting(settings, 'security.auth.selectedType', parsed.authType); - persistSetting(settings, 'model.name', parsed.modelId); + persistSetting( + settings, + 'security.auth.selectedType', + parsed.authType, + scopeOverride, + ); + persistSetting(settings, 'model.name', parsed.modelId, scopeOverride); // `/model ` selects by id only, so clear any baseUrl disambiguator left // by a previous model-picker selection — otherwise next launch would // resolve to a different provider than this switch just chose. Use an // empty-string tombstone so the clear overrides a lower-scope value (an // undefined write is dropped from JSON and would not override on merge). - persistSetting(settings, 'model.baseUrl', ''); + persistSetting(settings, 'model.baseUrl', '', scopeOverride); return parsed.modelId; } await config.switchModel(currentAuthType, modelArg, undefined); - persistSetting(settings, 'model.name', modelArg); - persistSetting(settings, 'model.baseUrl', ''); + persistSetting(settings, 'model.name', modelArg, scopeOverride); + persistSetting(settings, 'model.baseUrl', '', scopeOverride); return modelArg; } @@ -264,10 +315,11 @@ export const modelCommand: SlashCommand = { completionPriority: 100, get description() { return t( - 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', + 'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).', ); }, - argumentHint: '[--fast|--voice|--vision] [] | ', + argumentHint: + '[--fast|--voice|--vision] [--project|--global] [] | ', kind: CommandKind.BUILT_IN, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, completion: async (context, partialArg) => { @@ -289,6 +341,18 @@ export const modelCommand: SlashCommand = { 'Set the image-capable model used to transcribe images for a text-only main model', ), }, + { + value: '--project', + description: t( + 'Persist the model selection to the project settings (workspace scope)', + ), + }, + { + value: '--global', + description: t( + 'Persist the model selection to the user settings (global scope)', + ), + }, ].filter((item) => item.value.startsWith(partialArg)); if (flagCompletions.length > 0) { return flagCompletions; @@ -296,17 +360,17 @@ export const modelCommand: SlashCommand = { const trimmed = partialArg.trim(); if (trimmed) { let mode: 'main' | 'fast' | 'voice' | 'vision' = 'main'; - let modelPrefix = trimmed; - if (trimmed.startsWith('--fast ')) { - mode = 'fast'; - modelPrefix = trimmed.slice('--fast '.length); - } else if (trimmed.startsWith('--voice ')) { - mode = 'voice'; - modelPrefix = trimmed.slice('--voice '.length); - } else if (trimmed.startsWith('--vision ')) { - mode = 'vision'; - modelPrefix = trimmed.slice('--vision '.length); - } + // Strip all known flags to isolate the model prefix for completion + const modelPrefix = trimmed + .replace(/(?:^|\s)--fast(?:\s|$)/, ' ') + .replace(/(?:^|\s)--voice(?:\s|$)/, ' ') + .replace(/(?:^|\s)--vision(?:\s|$)/, ' ') + .replace(/(?:^|\s)--project(?:\s|$)/, ' ') + .replace(/(?:^|\s)--global(?:\s|$)/, ' ') + .trim(); + if (/(?:^|\s)--fast(?:\s|$)/.test(trimmed)) mode = 'fast'; + else if (/(?:^|\s)--voice(?:\s|$)/.test(trimmed)) mode = 'voice'; + else if (/(?:^|\s)--vision(?:\s|$)/.test(trimmed)) mode = 'vision'; return getAvailableModelIds(context, mode).filter((id) => id.startsWith(modelPrefix), ); @@ -333,8 +397,43 @@ export const modelCommand: SlashCommand = { }; } - // Handle --fast flag: /model --fast - const args = context.invocation?.args?.trim() || actionArgs.trim(); + // Parse --project / --global scope flags first, then process the rest + const rawArgs = context.invocation?.args?.trim() || actionArgs.trim(); + const { + scopeOverride, + remaining: args, + hasProject, + hasGlobal, + } = parseScopeFlags(rawArgs); + // Reject mutually exclusive scope flags + if (hasProject && hasGlobal) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot use both --project and --global. Choose one scope flag.', + ), + }; + } + // Reject --project when workspace is untrusted — workspace settings are + // ignored on merge, so the save would silently not take effect. + if ( + scopeOverride === SettingScope.Workspace && + settings && + !settings.isTrusted + ) { + return { + type: 'message', + messageType: 'error', + content: t('Workspace is untrusted; run /trust first or use --global.'), + }; + } + const scopeSuffix = + scopeOverride === SettingScope.Workspace + ? t(' (this project)') + : scopeOverride === SettingScope.User + ? t(' (global)') + : ''; const isVoiceModelCommand = args === '--voice' || args.startsWith('--voice '); if (isVoiceModelCommand) { @@ -356,6 +455,7 @@ export const modelCommand: SlashCommand = { return { type: 'dialog', dialog: 'voice-model', + ...persistScopeSpread(scopeOverride), }; } @@ -399,11 +499,11 @@ export const modelCommand: SlashCommand = { }; } - persistSetting(settings, 'voiceModel', modelName); + persistSetting(settings, 'voiceModel', modelName, scopeOverride); return { type: 'message', messageType: 'info', - content: t('Voice Model') + ': ' + modelName, + content: t('Voice Model') + ': ' + modelName + scopeSuffix, }; } @@ -424,6 +524,7 @@ export const modelCommand: SlashCommand = { return { type: 'dialog', dialog: 'fast-model', + ...persistScopeSpread(scopeOverride), }; } // Set fast model @@ -480,14 +581,14 @@ export const modelCommand: SlashCommand = { }; } - persistSetting(settings, 'fastModel', modelName); + persistSetting(settings, 'fastModel', modelName, scopeOverride); // Sync the runtime Config so forked agents pick up the change immediately // without requiring a restart. config.setFastModel(modelName); return { type: 'message', messageType: 'info', - content: t('Fast Model') + ': ' + modelName, + content: t('Fast Model') + ': ' + modelName + scopeSuffix, }; } @@ -517,6 +618,7 @@ export const modelCommand: SlashCommand = { return { type: 'dialog', dialog: 'vision-model', + ...persistScopeSpread(scopeOverride), }; } if (!settings) { @@ -593,7 +695,7 @@ export const modelCommand: SlashCommand = { const visionModel = matched.baseUrl ? `${qualifiedModelName}\0${matched.baseUrl}` : qualifiedModelName; - persistSetting(settings, 'visionModel', visionModel); + persistSetting(settings, 'visionModel', visionModel, scopeOverride); // Sync runtime Config so the vision bridge picks it up without a restart. config.setVisionModel(visionModel); // The pin is honored even if the model isn't image-capable (the user may @@ -604,7 +706,8 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: t('Vision Model') + ': ' + modelName + visionWarning, + content: + t('Vision Model') + ': ' + modelName + scopeSuffix + visionWarning, }; } @@ -669,6 +772,25 @@ export const modelCommand: SlashCommand = { ), }; } + // Scope flags are silently consumed by parseScopeFlags but the inline + // prompt path doesn't persist the model. Reject the combination to avoid + // surprising the user with a "(this project)" confirmation that never + // took effect. + if (scopeOverride) { + const scopeFlag = hasProject + ? '--project' + : hasGlobal + ? '--global' + : ''; + return { + type: 'message', + messageType: 'error', + content: t( + "Cannot combine {{flag}} with an inline prompt. Run '/model {{flag}} {{model}}' first, then send your prompt.", + { flag: scopeFlag }, + ), + }; + } // The per-turn override reuses the active provider's endpoint and // credentials and only swaps the model id; it cannot rebuild // baseUrl/envKey for a different provider. So the target must resolve to @@ -713,11 +835,12 @@ export const modelCommand: SlashCommand = { settings, authType, modelName, + scopeOverride, ); return { type: 'message', messageType: 'info', - content: t('Model') + ': ' + effectiveModelName, + content: t('Model') + ': ' + effectiveModelName + scopeSuffix, }; } @@ -728,13 +851,17 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Current model: ${currentModel}\nUse "/model " to switch models or "/model --fast " to set the fast model.`, + content: t( + 'Current model: {{model}}\nUse "/model " to switch models, "/model --fast " to set the fast model, "/model --project " to persist to project settings, or "/model --global " to persist to user settings.', + { model: currentModel }, + ), }; } return { type: 'dialog', dialog: 'model', + ...persistScopeSpread(scopeOverride), }; }, }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 85eb759917..d8b402594b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -165,6 +165,12 @@ export interface OpenDialogActionReturn { /** Optional session name for /branch — passed through to handleBranch. */ name?: string; + /** + * Optional persist scope for model dialog — controls which settings file + * the model selection is written to ('workspace' = project, 'user' = global). + */ + persistScope?: 'workspace' | 'user'; + dialog: | 'help' | 'arena_start' diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 3bcb5288d9..a611cae428 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -272,6 +272,7 @@ export const DialogManager = ({ isFastModelMode={uiState.isFastModelMode} isVoiceModelMode={uiState.isVoiceModelMode} isVisionModelMode={uiState.isVisionModelMode} + persistScope={uiState.modelDialogPersistScope} availableTerminalHeight={listDialogHeight} /> ); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index b0513afe49..d8158e72cc 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -20,6 +20,7 @@ import { type ContentGeneratorConfig, type InputModalities, } from '@qwen-code/qwen-code-core'; +import { SettingScope } from '../../config/settings.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { theme } from '../semantic-colors.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; @@ -115,6 +116,8 @@ interface ModelDialogProps { isFastModelMode?: boolean; isVoiceModelMode?: boolean; isVisionModelMode?: boolean; + /** Override which settings scope to persist the selection to. */ + persistScope?: 'workspace' | 'user'; availableTerminalHeight?: number; } @@ -142,12 +145,26 @@ function maskApiKey(apiKey: string | undefined): string { return `${head}…${tail}`; } +function resolvePersistScope( + settings: ReturnType, + persistScope: 'workspace' | 'user' | undefined, +): SettingScope { + // Workspace settings are ignored when untrusted, so fall back to user scope. + if (persistScope === 'workspace' && !settings.isTrusted) { + return SettingScope.User; + } + if (persistScope === 'workspace') return SettingScope.Workspace; + if (persistScope === 'user') return SettingScope.User; + return getPersistScopeForModelSelection(settings); +} + function persistModelSelection( settings: ReturnType, modelId: string, baseUrl?: string, + persistScope?: 'workspace' | 'user', ): void { - const scope = getPersistScopeForModelSelection(settings); + const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'model.name', modelId); // Persist the paired baseUrl so the correct provider is restored on next // launch when multiple providers share the same model id. When the selection @@ -160,8 +177,9 @@ function persistModelSelection( function persistAuthTypeSelection( settings: ReturnType, authType: AuthType, + persistScope?: 'workspace' | 'user', ): void { - const scope = getPersistScopeForModelSelection(settings); + const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'security.auth.selectedType', authType); } @@ -191,6 +209,7 @@ interface HandleModelSwitchSuccessParams { effectiveModelId: string; effectiveBaseUrl: string | undefined; isRuntime: boolean; + persistScope?: 'workspace' | 'user'; } function handleModelSwitchSuccess({ @@ -201,21 +220,33 @@ function handleModelSwitchSuccess({ effectiveModelId, effectiveBaseUrl, isRuntime, + persistScope, }: HandleModelSwitchSuccessParams): void { - persistModelSelection(settings, effectiveModelId, effectiveBaseUrl); + persistModelSelection( + settings, + effectiveModelId, + effectiveBaseUrl, + persistScope, + ); if (effectiveAuthType) { - persistAuthTypeSelection(settings, effectiveAuthType); + persistAuthTypeSelection(settings, effectiveAuthType, persistScope); } const baseUrl = after?.baseUrl ?? t('(default)'); const maskedKey = maskApiKey(after?.apiKey); + const scopeSuffix = + persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; uiState?.historyManager.addItem( { type: 'info', text: `authType: ${effectiveAuthType ?? `(${t('none')})`}` + `\n` + - `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}` + + `Using ${isRuntime ? 'runtime ' : ''}model: ${effectiveModelId}${scopeSuffix}` + `\n` + `Base URL: ${baseUrl}` + `\n` + @@ -254,6 +285,7 @@ export function ModelDialog({ isFastModelMode, isVoiceModelMode, isVisionModelMode, + persistScope, availableTerminalHeight, }: ModelDialogProps): React.JSX.Element { const config = useContext(ConfigContext); @@ -605,12 +637,18 @@ export function ModelDialog({ return; } - const scope = getPersistScopeForModelSelection(settings); + const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'voiceModel', voiceModel); + const scopeSuffix = + persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; uiState?.historyManager.addItem( { type: 'success', - text: `${t('Voice Model')}: ${voiceModel}`, + text: `${t('Voice Model')}: ${voiceModel}${scopeSuffix}`, }, Date.now(), ); @@ -624,14 +662,20 @@ export function ModelDialog({ // providers remain unambiguous. baseUrl is intentionally discarded. if (isFastModelMode) { const fastModel = encodeAuxModelSelector(selected); - const scope = getPersistScopeForModelSelection(settings); + const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'fastModel', fastModel); // Sync the runtime Config so forked agents pick up the change immediately. config?.setFastModel(fastModel); + const scopeSuffix = + persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; uiState?.historyManager.addItem( { type: 'success', - text: `${t('Fast Model')}: ${fastModel}`, + text: `${t('Fast Model')}: ${fastModel}${scopeSuffix}`, }, Date.now(), ); @@ -659,7 +703,7 @@ export function ModelDialog({ ); return; } - const scope = getPersistScopeForModelSelection(settings); + const scope = resolvePersistScope(settings, persistScope); settings.setValue(scope, 'visionModel', visionModel); // Sync runtime Config so the vision bridge picks it up without a restart. config?.setVisionModel(visionModel); @@ -669,10 +713,16 @@ export function ModelDialog({ selectedEntry && !isImageCapable(selectedEntry.model) ? `\n${t("⚠ '{{model}}' is not a known image-capable model; the vision bridge may fail on images.", { model: visionModelDisplay })}` : ''; + const scopeSuffix = + persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : ''; uiState?.historyManager.addItem( { type: 'success', - text: `${t('Vision Model')}: ${visionModelDisplay}${visionWarning}`, + text: `${t('Vision Model')}: ${visionModelDisplay}${scopeSuffix}${visionWarning}`, }, Date.now(), ); @@ -783,6 +833,7 @@ export function ModelDialog({ ? undefined : (after?.baseUrl ?? selectedEntry?.model.baseUrl), isRuntime, + persistScope, }); onClose(); }, @@ -797,6 +848,7 @@ export function ModelDialog({ isVoiceModelMode, isVisionModelMode, availableModelEntries, + persistScope, ], ); @@ -811,13 +863,18 @@ export function ModelDialog({ width="100%" > - {isVoiceModelMode + {(isVoiceModelMode ? t('Select Voice Model') : isVisionModelMode ? t('Select Vision Model') : isFastModelMode ? t('Select Fast Model') - : t('Select Model')} + : t('Select Model')) + + (persistScope === 'workspace' + ? t(' (this project)') + : persistScope === 'user' + ? t(' (global)') + : '')} {!hasModels ? ( diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 95d9d57db1..98951dbc47 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -69,6 +69,7 @@ export interface UIState { isFastModelMode: boolean; isVoiceModelMode: boolean; isVisionModelMode: boolean; + modelDialogPersistScope: 'workspace' | 'user' | undefined; isTrustDialogOpen: boolean; activeArenaDialog: ArenaDialogType; isPermissionsDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 3f8a33e7ea..73ece32ba8 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -122,6 +122,7 @@ export interface SlashCommandProcessorActions { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + persistScope?: 'workspace' | 'user'; }) => void; openTrustDialog: () => void; openPermissionsDialog: () => void; @@ -822,16 +823,27 @@ export const useSlashCommandProcessor = ( actions.openMemoryDialog(); return { type: 'handled' }; case 'model': - actions.openModelDialog(); + actions.openModelDialog({ + persistScope: result.persistScope, + }); return { type: 'handled' }; case 'fast-model': - actions.openModelDialog({ fastModelMode: true }); + actions.openModelDialog({ + fastModelMode: true, + persistScope: result.persistScope, + }); return { type: 'handled' }; case 'voice-model': - actions.openModelDialog({ voiceModelMode: true }); + actions.openModelDialog({ + voiceModelMode: true, + persistScope: result.persistScope, + }); return { type: 'handled' }; case 'vision-model': - actions.openModelDialog({ visionModelMode: true }); + actions.openModelDialog({ + visionModelMode: true, + persistScope: result.persistScope, + }); return { type: 'handled' }; case 'trust': actions.openTrustDialog(); diff --git a/packages/cli/src/ui/hooks/useModelCommand.ts b/packages/cli/src/ui/hooks/useModelCommand.ts index 67ff02b2d1..377fe5bd7c 100644 --- a/packages/cli/src/ui/hooks/useModelCommand.ts +++ b/packages/cli/src/ui/hooks/useModelCommand.ts @@ -6,15 +6,19 @@ import { useState, useCallback } from 'react'; +type ModelDialogPersistScope = 'workspace' | 'user'; + interface UseModelCommandReturn { isModelDialogOpen: boolean; isFastModelMode: boolean; isVoiceModelMode: boolean; isVisionModelMode: boolean; + modelDialogPersistScope: ModelDialogPersistScope | undefined; openModelDialog: (options?: { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + persistScope?: ModelDialogPersistScope; }) => void; closeModelDialog: () => void; } @@ -24,12 +28,16 @@ export const useModelCommand = (): UseModelCommandReturn => { const [isFastModelMode, setIsFastModelMode] = useState(false); const [isVoiceModelMode, setIsVoiceModelMode] = useState(false); const [isVisionModelMode, setIsVisionModelMode] = useState(false); + const [modelDialogPersistScope, setModelDialogPersistScope] = useState< + ModelDialogPersistScope | undefined + >(undefined); const openModelDialog = useCallback( (options?: { fastModelMode?: boolean; voiceModelMode?: boolean; visionModelMode?: boolean; + persistScope?: ModelDialogPersistScope; }) => { const voiceModelMode = options?.voiceModelMode ?? false; const visionModelMode = options?.visionModelMode ?? false; @@ -43,6 +51,7 @@ export const useModelCommand = (): UseModelCommandReturn => { // in two specialized modes at once (mismatched title vs. highlighted row). setIsVoiceModelMode(visionModelMode ? false : voiceModelMode); setIsVisionModelMode(visionModelMode); + setModelDialogPersistScope(options?.persistScope); setIsModelDialogOpen(true); }, [], @@ -53,6 +62,7 @@ export const useModelCommand = (): UseModelCommandReturn => { setIsFastModelMode(false); setIsVoiceModelMode(false); setIsVisionModelMode(false); + setModelDialogPersistScope(undefined); }, []); return { @@ -60,6 +70,7 @@ export const useModelCommand = (): UseModelCommandReturn => { isFastModelMode, isVoiceModelMode, isVisionModelMode, + modelDialogPersistScope, openModelDialog, closeModelDialog, }; diff --git a/packages/core/src/tools/mcp-transport-pool.test.ts b/packages/core/src/tools/mcp-transport-pool.test.ts index f6d0cae245..03e16c8943 100644 --- a/packages/core/src/tools/mcp-transport-pool.test.ts +++ b/packages/core/src/tools/mcp-transport-pool.test.ts @@ -473,8 +473,9 @@ describe('McpTransportPool', () => { // synchronously when localStatus flips to DISCONNECTED on an // active entry (gated by !restartInProgress so intentional // restart-mid-disconnect doesn't trip it). - const { updateMCPServerStatus, MCPServerStatus } = - await import('./mcp-client.js'); + const { updateMCPServerStatus, MCPServerStatus } = await import( + './mcp-client.js' + ); mockMcpSuccess({ toolNames: ['t1'] }); const pool = new McpTransportPool(cliConfig, mkPoolOptions()); const cfg = new MCPServerConfig('node'); @@ -546,8 +547,9 @@ describe('McpTransportPool', () => { // snapshot — same zombie-attach failure, shifted into drain. // Post-W131 the gate extends to ('active' || 'draining') and // cancels the drain timer in the same step. - const { updateMCPServerStatus, MCPServerStatus } = - await import('./mcp-client.js'); + const { updateMCPServerStatus, MCPServerStatus } = await import( + './mcp-client.js' + ); mockMcpSuccess({ toolNames: ['t1'] }); const pool = new McpTransportPool( cliConfig, @@ -895,8 +897,9 @@ describe('McpTransportPool', () => { // `getLastTransportError()` returns undefined → caller falls back // to the synthetic-only string. This test pins the behavior // existing W120/W131 tests relied on pre-fix. - const { updateMCPServerStatus, MCPServerStatus } = - await import('./mcp-client.js'); + const { updateMCPServerStatus, MCPServerStatus } = await import( + './mcp-client.js' + ); mockMcpSuccess({ toolNames: ['t1'] }); const pool = new McpTransportPool(cliConfig, mkPoolOptions()); const cfg = new MCPServerConfig('node'); @@ -1013,8 +1016,9 @@ describe('McpTransportPool', () => { // silent-drop chain compares descendantsSignaled vs // descendantsFound and emits the same outer warn even though // sweepAndDisconnect itself didn't throw. - const { listDescendantPids, sigtermPids } = - await import('./pid-descendants.js'); + const { listDescendantPids, sigtermPids } = await import( + './pid-descendants.js' + ); const { createDebugLogger } = await import('../utils/debugLogger.js'); const debugMock = createDebugLogger('McpPool:Entry'); // The stub is a singleton across tests; clear prior call history