diff --git a/docs/superpowers/plans/2026-06-30-channel-loop.md b/docs/superpowers/plans/2026-06-30-channel-loop.md index 40a0240bbee..739f3c8148d 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 expect(new ChannelLoopStore({ filePath }).list()).resolves.toMatchObject([ - { id: 'loop-legacy', runCount: 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 }]); }); 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 3f2aef1af8f..d34bceddb59 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 c6f6b88176b..61ede7a27d5 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 d6c5601ee37..9306775b8d0 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 7f8d3a8d858..6e9e9b6cab4 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 8b5fb4a05f6..f59a257e294 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 cf038b40610..8fca65b9849 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 482e828f125..4d0a16b23aa 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 4aa6bd83122..a0d2c3e77e6 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 446c845a570..93ae32fabe2 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 94fc7f870c1..29d9f94da34 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 85eb7599177..d8b402594b5 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 3bcb5288d9a..a611cae428b 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 b0513afe491..d8158e72cc8 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 95d9d57db16..98951dbc476 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 3f8a33e7ea5..73ece32ba80 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 67ff02b2d12..377fe5bd7c4 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 f6d0cae245b..03e16c8943b 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