Skip to content

feat(cli): add --project and --global flags to /model for per-project model persistence#6060

Open
Alex-ai-future wants to merge 11 commits into
QwenLM:mainfrom
Alex-ai-future:dive/muti-model
Open

feat(cli): add --project and --global flags to /model for per-project model persistence#6060
Alex-ai-future wants to merge 11 commits into
QwenLM:mainfrom
Alex-ai-future:dive/muti-model

Conversation

@Alex-ai-future

Copy link
Copy Markdown
Contributor

What this PR does

Adds --project and --global scope flags to the /model command, allowing users to choose which settings file the model selection is persisted to.

  • /model --project opens the model picker and writes the selection to the project-level .qwen/settings.json (workspace scope)
  • /model --global opens the model picker and writes to the user-level ~/.qwen/settings.json (global scope)
  • /model --project <model-id> sets the model directly and persists to project scope, showing Model: qwen-max (project) as confirmation
  • /model --global <model-id> sets the model directly and persists to global scope, showing Model: qwen-max (global)
  • /model without flags retains the current behavior (backward compatible)
  • The model picker dialog title reflects the active scope: "Select Model (this project)" or "Select Model (global)"
  • The scope flags also apply to --fast, --voice, and --vision subcommands
  • Autocomplete and argumentHint updated to surface the new flags
  • Full i18n support for zh and en locales

The implementation threads a persistScope value through the existing dialog pipeline: modelCommand -> OpenDialogActionReturn -> slashCommandProcessor -> useModelCommand -> UIState -> DialogManager -> ModelDialog. The ModelDialog uses resolvePersistScope() to map the string scope to a SettingScope enum, overriding the default getPersistScopeForModelSelection behavior when a scope is explicitly provided.

Why it's needed

Different projects have different complexity and cost requirements. A simple scripting project may only need a lightweight model, while a complex architecture project needs a stronger one. Today, /model always writes to the scope that owns modelProviders (usually ~/.qwen/settings.json), so switching models in one project affects all projects globally. The only workaround is to duplicate the entire modelProviders config at project level, which is impractical.

This PR implements the "explicit flags" approach from the #6052 triage discussion — keeping the current default (global write) while adding opt-in --project / --global flags. This is non-breaking and gives users precise control without surprising behavior changes.

Reviewer Test Plan

How to verify

  1. Run /model --project — the dialog title should show "Select Model (this project)". Select a model. Verify it is written to <project>/.qwen/settings.json (not ~/.qwen/settings.json).
  2. Run /model --global — the dialog title should show "Select Model (global)". Select a model. Verify it is written to ~/.qwen/settings.json.
  3. Run /model --project qwen-max — should show "Model: qwen-max (project)" and write to project settings.
  4. Run /model (no flag) — behavior unchanged, writes to the scope that owns modelProviders (current behavior).
  5. Type /model in the input — autocomplete should show --project and --global alongside existing flags.
  6. Run /model --project --fast qwen3-coder-flash — should write fast model to project scope.

Evidence (Before & After)

Before: /model always writes to global settings. No way to set per-project model without duplicating modelProviders.

After: /model --project qwen-max writes to project settings. /model --global qwen-max writes to global settings. Dialog title shows scope.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Environment (optional)

npm run dev

Risk & Scope

  • Main risk or tradeoff: None — the change is purely additive. Existing /model behavior is unchanged when no flag is provided.
  • Not validated / out of scope: Cross-provider model switching with --project flag (the model must be available under the current auth type). The --project flag for daemon/ACP sessions is not explicitly handled but should work since the settings merge logic is the same.
  • Breaking changes / migration notes: None.

Linked Issues

Closes #6052

中文说明

这个 PR 做了什么

/model 命令添加 --project--global scope 标志,让用户可以选择模型选择持久化到哪个设置文件。

  • /model --project 打开模型选择器,写入项目级 .qwen/settings.json
  • /model --global 打开模型选择器,写入用户级 ~/.qwen/settings.json
  • /model --project <model-id> 直接设置模型并持久化到项目级,显示 Model: qwen-max (project)
  • /model 不带标志保持当前行为(向后兼容)
  • 模型选择器标题显示当前 scope
  • scope 标志也适用于 --fast--voice--vision
  • 自动补全和 argumentHint 已更新
  • 完整的中文/英文 i18n 支持

为什么需要

不同项目有不同的复杂度和成本需求。今天 /model 总是写入 modelProviders 所在的 scope(通常是全局),导致在一个项目里切换模型会影响所有项目。唯一的变通方法是在项目级复制整个 modelProviders 配置,这不现实。

本 PR 实现了 #6052 triage 讨论中的"显式标志"方案——保持当前默认行为(全局写入),同时添加 opt-in 的 --project / --global 标志。非破坏性,给用户精确控制。

审查者测试计划

如何验证

  1. 运行 /model --project — 标题应显示"选择模型(当前项目)",选择后验证写入项目级设置
  2. 运行 /model --global — 标题应显示"选择模型(全局)",选择后验证写入全局设置
  3. 运行 /model --project qwen-max — 应显示"模型:qwen-max(项目)"
  4. 运行 /model(无标志)— 行为不变
  5. 输入 /model — 自动补全应显示 --project--global

证据(前后对比)

之前:/model 总是写入全局设置,无法设置项目级模型。

之后:/model --project qwen-max 写入项目设置,/model --global qwen-max 写入全局设置。

测试环境

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

风险与范围

  • 主要风险:无——纯增量改动,不带标志时行为完全不变
  • 未验证:daemon/ACP session 下的 --project 标志
  • 破坏性变更:无

关联 Issue

Closes #6052

let scopeOverride: SettingScope | undefined;
let remaining = args;

if (remaining.startsWith('--project')) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: parseScopeFlags only recognizes --project/--global at the start of the argument string.

startsWith('--project') means /model --fast --project my-model silently drops the scope flag. The fast-model branch then strips --fast and treats --project my-model as the model name, which fails resolveModelId() and produces a confusing error:

Fast model '--project my-model' is not configured for any auth type.

Same issue for --vision --global, --voice --global, etc. The argumentHint lists these flags flat ([--fast|--voice|--vision|--project|--global]), so users will expect order-independent composition.

Suggested fix — extract scope flags from anywhere in the string before dispatching:

function parseScopeFlags(args: string) {
  let scopeOverride: SettingScope | undefined;
  let remaining = args;

  if (/\b--project\b/.test(remaining)) {
    scopeOverride = SettingScope.Workspace;
    remaining = remaining.replace(/\s*--project\s*/, ' ').trim();
  } else if (/\b--global\b/.test(remaining)) {
    scopeOverride = SettingScope.User;
    remaining = remaining.replace(/\s*--global\s*/, ' ').trim();
  }

  return { scopeOverride, remaining };
}

The same fix should be applied to the completion branch (~line 299–310) where --project / --global are only stripped when they appear at the start of trimmed.

dialog: 'model',
...(scopeOverride === SettingScope.Workspace
? { persistScope: 'workspace' as const }
: scopeOverride === SettingScope.User

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] persistScope is correctly propagated for the main 'model' dialog here, but silently dropped for all three subcommand dialog paths: 'voice-model', 'fast-model', and 'vision-model'. Their return objects (lines ~355, ~424, ~514) omit persistScope, and the corresponding cases in slashCommandProcessor.ts (lines ~830, ~833, ~836) call openModelDialog({ fastModelMode: true }) etc. without forwarding result.persistScope.

The result: /model --project --fast opens the model picker, the user selects a model, and it silently writes to the default scope (potentially global) instead of the requested project scope. No error, no warning.

Both layers need fixing:

  1. Add persistScope to each subcommand dialog return (same spread pattern as above):
return {
  type: 'dialog',
  dialog: 'fast-model',
  ...(scopeOverride === SettingScope.Workspace
    ? { persistScope: 'workspace' as const }
    : scopeOverride === SettingScope.User
      ? { persistScope: 'user' as const }
      : {}),
};
  1. Forward it in slashCommandProcessor.ts:
case 'fast-model':
  actions.openModelDialog({ fastModelMode: true, persistScope: result.persistScope });
case 'voice-model':
  actions.openModelDialog({ voiceModelMode: true, persistScope: result.persistScope });
case 'vision-model':
  actions.openModelDialog({ visionModelMode: true, persistScope: result.persistScope });

— qwen3.7-max via Qwen Code /review

@@ -769,13 +793,17 @@ export function ModelDialog({
width="100%"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] When both persistScope and a subcommand mode are set, the user sees "Select Model (this project)" instead of a combined title like "Select Fast Model (this project)". The persistScope check at the top of the ternary overrides all subcommand-specific titles.

Currently masked by the Critical finding above (scope never reaches subcommand dialogs), but will surface immediately once that's fixed. Consider combining both dimensions:

{isVoiceModelMode
  ? t('Select Voice Model') + (persistScope === 'workspace' ? ' (this project)' : persistScope === 'user' ? ' (global)' : '')
  : isVisionModelMode
    ? t('Select Vision Model') + scopeLabel
    : isFastModelMode
      ? t('Select Fast Model') + scopeLabel
      : t('Select Model') + scopeLabel}

— qwen3.7-max via Qwen Code /review

@@ -345,7 +398,7 @@ export const modelCommand: SlashCommand = {
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The main model path correctly shows scope-aware confirmation (Model: {{model}} (project) / Model: {{model}} (global)), but the voice, fast, and vision subcommand paths show no scope indication:

  • Line ~405: t('Voice Model') + ': ' + modelName
  • Line ~489: t('Fast Model') + ': ' + modelName
  • Line ~589: t('Vision Model') + ': ' + modelName + visionWarning

Users can't tell from the confirmation whether the write went to project or global scope. Apply the same contentKey pattern used for the main model:

const scopeSuffix =
  scopeOverride === SettingScope.Workspace ? ' (project)' :
  scopeOverride === SettingScope.User ? ' (global)' : '';
content: t('Voice Model') + ': ' + modelName + scopeSuffix,

— qwen3.7-max via Qwen Code /review

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).',
'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).',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The only test change in this PR is the description string update. There are no tests for:

  • parseScopeFlags() function
  • resolveScope() with scope overrides
  • Scope-aware persistSetting() calls
  • Scope appearing in confirmation messages
  • persistScope in dialog return objects
  • Scope flag + --voice/--fast/--vision flag interactions

The Critical finding (persistScope silently dropped for subcommand dialogs) would have been caught by a single test asserting that /model --project --fast returns a dialog action with persistScope: 'workspace'.

— qwen3.7-max via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental Review — 9e18b4a (fix: TS index signature)

Scope: 1 file, +12/-5 in modelCommand.test.ts

Change: Replaces Record<string, unknown> casts with Partial<Config> & { [key: string]: unknown } in three places to fix a TypeScript index signature compatibility error.

Verdict: LGTM. Clean, minimal type fix. Config is already imported, and the mocked methods (getAvailableModelsForAuthType, getAllConfiguredModels) are real Config class methods. No behavioral changes.

No high-confidence issues found.

@@ -544,7 +566,7 @@ export function ModelDialog({
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The resolvePersistScope change is correctly applied here (and at lines 585, 639), but the corresponding historyManager.addItem calls at lines 571, 592, 652 don't indicate which scope was written. When a user runs /model --project → picks from the dialog, they see "Voice Model: <name>" without any scope hint — unlike the CLI direct-set path which correctly shows "Voice Model: <name> (project)".

Consider adding a scope suffix to the dialog history items for consistency:

const scopeSuffix = persistScope === 'workspace' ? t(' (this project)')
  : persistScope === 'user' ? t(' (global)') : '';
// ...
text: `${t('Voice Model')}: ${voiceModel}${scopeSuffix}`,

Same pattern applies to the fast model (line 592), vision model (line 652), and main model (line 208) history items.

— qwen3.7-max via Qwen Code /review

… 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 QwenLM#6052

Signed-off-by: Alex <alex.tech.lab@outlook.com>
Signed-off-by: Alex <alex.tech.lab@outlook.com>
…itles, 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 <alex.tech.lab@outlook.com>
…ignature TS error

Replace Record<string,unknown> with Partial<Config> & {[key:string]:unknown}
to satisfy TS4111 index signature access rule in the CI build.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
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 <alex.tech.lab@outlook.com>
@github-actions

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental Review — f1589c4 (scope suffix for ModelDialog history items)

Scope reviewed: 7 source files + 3 i18n files (+394/-47 vs main). Incremental since 9e18b4a: 5 commits including 459730456 (address PR review feedback), f1589c4be (scope suffix for history items), and upstream merges.

Changes since last review (9e18b4a)

  1. ModelDialog.tsxscopeSuffix (project/global) now appended to historyManager.addItem text in all 4 model-switch success paths: main, voice, fast, and vision. This addresses @doudouOUC's earlier suggestion about history items not indicating scope.
  2. Upstream mergesmatchesAnyServerPattern adoption across MCP management UI, collapsePreviewCount feature. These are from merged upstream PRs, not PR-specific.

Findings

No new high-confidence issues. All previously reported issues have been addressed:

  • persistScope silently dropped for subcommand dialog paths — Fixed in 459730456. All 4 dialog return objects now spread ...persistScopeSpread(scopeOverride).
  • parseScopeFlags position-dependent — Fixed in 459730456. Now uses (?:^|\s)--flag(?:\s|$) regex.
  • Subcommand confirmation messages lack scope indication — Fixed. All paths include scopeSuffix.
  • Dialog title doesn't combine scope with subcommand mode — Fixed. Title appends scope suffix after mode ternary.

Remaining open suggestions (not blocking)

  • The scopeSuffix ternary is duplicated 5 times across ModelDialog.tsx and modelCommand.ts. A small shared helper (e.g., formatScopeSuffix) would reduce repetition.
  • No unit tests for parseScopeFlags(), resolveScope(), or scope-aware persist paths (previously noted by @doudouOUC).

Verdict: The implementation is correct and the previously identified issues have been properly resolved.

…bal with inline one-shot

Merge our --project/--global scope flags with upstream's inline one-shot
override feature (QwenLM#5967). Both features coexist in description, argumentHint,
and i18n translation keys across en/zh/zh-TW locales.

Signed-off-by: Alex <alex.tech.lab@outlook.com>
// Parse --project / --global scope flags first, then process the rest
const rawArgs = context.invocation?.args?.trim() || actionArgs.trim();
const { scopeOverride, remaining: args } = parseScopeFlags(rawArgs);
const scopeSuffix =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug (i18n + inconsistency): scopeSuffix uses hardcoded English strings ' (project)' and ' (global)' without t() wrapping. This is consumed by voice/fast/vision confirmation messages (lines ~422, ~506, ~609), so non-English users see English text for those confirmations.

Additionally, the wording is inconsistent with ModelDialog.tsx, which uses t(' (this project)') (note "this project" vs just "project"). The main model path here uses t('Model: {{model}} (project)') — a third variant.

Suggested fix: Wrap in t() and align the wording with ModelDialog.tsx:

const scopeSuffix =
  scopeOverride === SettingScope.Workspace
    ? t(' (this project)')
    : scopeOverride === SettingScope.User
      ? t(' (global)')
      : '';

This reuses the existing zh translation ' (this project)': '(当前项目)' and ' (global)': '(全局)'.

- 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 <alex.tech.lab@outlook.com>
@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Merge Conflict Resolution Summary

Conflicts Found

Two files had merge conflicts when merging origin/main into the PR branch:

  1. packages/cli/src/ui/commands/modelCommand.ts (2 conflicts)
  2. packages/cli/src/ui/components/ModelDialog.tsx (1 conflict)

Resolution Details

Conflict 1: modelCommand.ts — Function definitions

HEAD (PR branch) added:

  • parseScopeFlags() — parses --project and --global flags
  • resolveScope() — resolves setting scope with override support
  • persistScopeSpread() — returns scope spread object for dialog actions

origin/main added:

  • formatVisionModelSettingForDisplay() — formats vision model settings for human-readable display

Resolution: Kept both sets of functions. They are independent and serve different purposes — scope management for the PR feature and display formatting from main.

Conflict 2: modelCommand.ts — Vision model persistence

HEAD (PR branch):

persistSetting(settings, 'visionModel', modelName, scopeOverride);

Persisted the raw model name with scope override support.

origin/main:

const qualifiedModelName = `${selector.authType ?? matched.authType}:${selector.modelId}`;
const visionModel = matched.baseUrl
  ? `${qualifiedModelName}\0${matched.baseUrl}`
  : qualifiedModelName;
persistSetting(settings, 'visionModel', visionModel);

Constructed a qualified model name with auth type and base URL for disambiguation, but without scope override.

Resolution: Combined both approaches — use main's qualified vision model construction (which properly disambiguates models across providers and encodes base URL) AND pass the scopeOverride parameter from HEAD:

const qualifiedModelName = `${selector.authType ?? matched.authType}:${selector.modelId}`;
const visionModel = matched.baseUrl
  ? `${qualifiedModelName}\0${matched.baseUrl}`
  : qualifiedModelName;
persistSetting(settings, 'visionModel', visionModel, scopeOv

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — qwen3.7-max

Build: ✅ PASSED | TypeScript: ✅ PASSED | Tests: ⚠️ Blocked (pre-existing @xterm/headless CJS/ESM interop, unrelated to PR)

Critical

1. Inline prompt + scope flag silently discards scope
packages/cli/src/ui/commands/modelCommand.ts (action function, inline prompt return path)

scopeOverride is correctly extracted by parseScopeFlags, but the inline prompt path returns { type: 'submit_prompt', content: inlinePrompt, modelOverride: parsed.modelId } without referencing scopeOverride or scopeSuffix. For /model --project qwen-max explain code, the --project flag is silently consumed — the model is NOT persisted to project settings, and the user gets no warning.

Fix: Either persist the model before returning submit_prompt, or reject the combination:

if (inlinePrompt && scopeOverride) {
  return {
    type: 'message',
    messageType: 'error',
    content: t('Cannot combine --project/--global with an inline prompt. Run \'/model --project <id>\' first, then send your prompt.'),
  };
}

2. Test gap — settings.setValue scope argument never verified
packages/cli/src/ui/commands/modelCommand.test.ts (scope suffix tests)

Both suffix tests (should show scope suffix in fast/main model confirmation) assert on result.content containing "(this project)" but never verify setValue was called with SettingScope.Workspace. The core feature — writing to the correct settings file — is untested.

Fix: Add assertions:

expect(setValue).toHaveBeenCalledWith(
  expect.stringContaining('workspace'),
  'fastModel',
  'qwen3-coder-flash',
);

Also add a --global variant asserting 'user' scope.

Suggestions

3. scopeSuffix ternary duplicated 5+ times across modelCommand.ts and ModelDialog.tsx. Extract a shared formatScopeSuffix() helper.

4. Dead i18n keys'Select Model (this project)' and 'Select Model (global)' in en.js:1691-1692 (and zh/zh-TW) are never referenced in source code. The dialog title concatenates t('Select Model') + suffix separately. Remove the unused composite keys.

5. Conflicting --project --global produces confusing errorparseScopeFlags uses if/else if; when both flags present, one remains in remaining and gets parsed as a model name → "Model '--global' not found". Add mutual exclusivity check.

6. ModelDialogPersistScope exported but never imported (useModelCommand.ts:9). All consumers use the raw 'workspace' | 'user' literal. Either import it or remove export.

7. Non-interactive help text not t()-wrapped (modelCommand.ts, no-model fallback). Raw template literal, unlike all other messages. Also missing --project, --global, --vision.

8. Scope suffix placed on API key line — In ModelDialog.tsx handleModelSwitchSuccess, scopeSuffix is appended to API key: sk...abc (this project) instead of the model line. All other paths place it after the model name.

9. Untrusted workspace: --project writes into voidresolveScope honors --project unconditionally, but mergeSettings replaces workspace with {} when isTrusted is false. User sees "(this project)" but setting is dormant.

10. Missing fr.js/ja.js translations — 4 actively-used i18n keys ( (this project), (global), 2 completion descriptions) are translated in en/zh/zh-TW but absent from fr/ja locales.

11. argumentHint groups all flags as mutually exclusive[--fast|--voice|--vision|--project|--global] should be [--fast|--voice|--vision] [--project|--global] since scope flags combine with mode flags.

12. Unrelated Prettier formatting in 6 filesfr.js, ja.js, background-agent-resume.test.ts, workflow-orchestrator.test.ts, agent.test.ts, agent.ts contain formatting-only changes. Revert to keep diff focused.

Needs Human Review

13. Cross-scope shadowing (low confidence) — When workspace settings already contain model.name, /model --global writes to user scope but workspace wins on merge. Confirmation says "(global)" but setting may not take effect.


— qwen3.7-max via Qwen Code /review

- Reject inline prompt + scope flag combination with clear error (#1)
- Add mutual exclusivity check for --project and --global (QwenLM#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 (QwenLM#8)
- Add fr.js / ja.js translations for scope keys (QwenLM#10)
- Remove unused export ModelDialogPersistScope (QwenLM#6)
- Wrap non-interactive help text in t() with new flags (QwenLM#7)
- Fix argumentHint grouping to show mode vs scope flags (QwenLM#11)

Signed-off-by: Alex <alex.tech.lab@outlook.com>
…delDialog.tsx

- modelCommand.ts: keep our scope flag parsing + upstream's formatVisionModelSettingForDisplay
- modelCommand.ts vision path: merge upstream's baseUrl/qualifiedModelName with our scopeOverride
- ModelDialog.tsx: merge upstream's visionModelDisplay with our scopeSuffix

Signed-off-by: Alex <alex.tech.lab@outlook.com>
settings: LoadedSettings,
scopeOverride: SettingScope | undefined,
): SettingScope {
return scopeOverride ?? getPersistScopeForModelSelection(settings);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] --project currently forces SettingScope.Workspace even when the workspace is untrusted. LoadedSettings ignores workspace settings in that state, and nearby workspace-scoped flows guard this before writing, so this can report a project save that will not actually take effect until the folder is trusted.

Reject --project when settings.isTrusted is false before direct persistence or opening ModelDialog, and mirror that guard in the dialog scope resolver. For example:

if (scopeOverride === SettingScope.Workspace && !settings.isTrusted) {
  return {
    type: 'message',
    messageType: 'error',
    content: t('Workspace is untrusted; run /trust first or use --global.'),
  };
}

— GPT-5 Codex via Qwen Code /review

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 <alex.tech.lab@outlook.com>

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incremental Review — 2ccb615055

Scope reviewed: 15 source files (+627/-120 vs main). Full review of correctness, security, and code quality.

Summary

This PR adds --project and --global scope flags to /model for per-project model persistence. The implementation threads a persistScope value through the command → dialog pipeline, with proper i18n, untrusted-workspace guards, and inline-prompt rejection.

Verdict: No new high-confidence issues found

All significant issues raised by previous reviewers (@DragonnZhang, @doudouOUC, @wenshao) have been addressed in the latest commits:

Issue Reporter Status
parseScopeFlags position-dependent matching @DragonnZhang Fixed — regex `(?:^
persistScope dropped for subcommand dialogs @doudouOUC Fixed — persistScopeSpread on all dialog returns
Dialog title not combining mode + scope @doudouOUC Fixed — ternary + suffix
Confirmation messages missing scope for subcommands @doudouOUC Fixed — scopeSuffix in all paths
Hardcoded English strings without t() @DragonnZhang Fixed — all wrapped in t()
--project when workspace is untrusted @wenshao Fixed — command-level guard + dialog fallback
Inline prompt + scope flag silent discard @wenshao Fixed — explicit error message
Test coverage gaps @doudouOUC Open (suggestion)

The scope propagation chain is correct end-to-end: modelCommand.actionOpenDialogActionReturn.persistScopeslashCommandProcessoropenModelDialog({persistScope})useModelCommand state → UIState.modelDialogPersistScopeDialogManagerModelDialog.persistScoperesolvePersistScope()settings.setValue(scope, ...).

The untrusted-workspace handling is properly layered: command-level rejection in modelCommand.ts prevents the dialog from opening, and resolvePersistScope in ModelDialog.tsx provides defense-in-depth by falling back to SettingScope.User.

Note: Test coverage for parseScopeFlags, resolveScope, scope-aware persistSetting, and scope in confirmation messages remains unaddressed (previously noted by @doudouOUC).

type: 'message',
messageType: 'error',
content: t(
"Cannot combine {{flag}} with an inline prompt. Run '/model {{flag}} {{model}}' first, then send your prompt.",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The t() call includes a {{model}} placeholder in the template string but the interpolation params only provide { flag: scopeFlag } — the model key is missing. The i18n interpolation renders unsubstituted placeholders as literal text, so users see:

Cannot combine --project with an inline prompt. Run '/model --project {{model}}' first, then send your prompt.

The {{model}} leaks raw into the UI. Note how the adjacent inline-prompt error at line ~771 correctly passes { model: modelName }.

Suggested change
"Cannot combine {{flag}} with an inline prompt. Run '/model {{flag}} {{model}}' first, then send your prompt.",
"Cannot combine {{flag}} with an inline prompt. Run '/model {{flag}} {{model}}' first, then send your prompt.",
{ flag: scopeFlag, model: modelName },

— qwen3.7-max via Qwen Code /review

type: 'message',
messageType: 'error',
content: t(
'Cannot use both --project and --global. Choose one scope flag.',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Several new t()-wrapped strings introduced by this PR are not registered in any locale file (en, fr, ja, zh, zh-TW). The t() function falls back to the key string for English, so English works, but non-English locales see untranslated English text. Missing keys:

  1. "Cannot use both --project and --global. Choose one scope flag." (line 414)
  2. "Workspace is untrusted; run /trust first or use --global." (line 428)
  3. "Cannot combine {{flag}} with an inline prompt. Run '/model {{flag}} {{model}}' first, then send your prompt." (line 789)
  4. "Current model: {{model}}\nUse \"/model <model-id>\" to switch models, \"/model --fast <model-id>\" to set the fast model, \"/model --project <model-id>\" to persist to project settings, or \"/model --global <model-id>\" to persist to user settings." (line 855)

The PR correctly added i18n entries for (this project), (global), the completion descriptions, and the updated command description — these four strings were missed. Add them to all five locale files for consistency.

— qwen3.7-max via Qwen Code /review

@DragonnZhang

Copy link
Copy Markdown
Collaborator

E2E Tmux Test Report -- PR #6060

Test Environment

  • OS: Ubuntu (Linux x86_64, kernel 7.0.0-22)
  • CLI: npm run dev (tsx dev mode, no full build)
  • Node: v22.22.2
  • Branch: dive/muti-model (commit 2ccb61505)
  • Sandbox: disabled (QWEN_SANDBOX=false)

Unit Test Results

Test Suite Tests Passed Failed
modelCommand.test.ts 67 67 0
config.test.ts 313 313 0

All 11 new scope-flag tests in the scope flags describe block pass:

  • persistScope in dialog return for /model --project (workspace)
  • persistScope in dialog return for /model --global (user)
  • persistScope for /model --project --fast dialog
  • persistScope for /model --global --voice dialog
  • persistScope for /model --project --vision dialog
  • Scope flags parsed in any position (--fast --project works)
  • Scope suffix in fast model confirmation: (this project)
  • Persist to global scope with --global: (global)
  • Reject --project when workspace is untrusted
  • Scope suffix in main model confirmation: (this project)
  • No persistScope when no scope flag given (backward compatible)

Tmux Interactive E2E Tests

Test 1: CLI Startup

  • Command: QWEN_SANDBOX=false npm run dev
  • Result: PASS -- CLI launched and displayed the Qwen Code banner with model qwen3.7-max and workspace path.

Test 2: /model --project (dialog mode)

  • Command: /model --project
  • Expected: Dialog title shows "Select Model (this project)"
  • Actual: Dialog rendered with title "Select Model (this project)" and listed all configured models.
  • Result: PASS

Test 3: /model --global (dialog mode)

  • Command: /model --global
  • Expected: Dialog title shows "Select Model (global)"
  • Actual: Dialog rendered with title "Select Model (global)" and listed all configured models.
  • Result: PASS

Test 4: /model --project <model-id> (direct set)

  • Command: /model --project qwen3.7-max
  • Expected: Confirmation shows Model: qwen3.7-max (this project) and writes to project settings.
  • Actual:
    • Confirmation: Model: qwen3.7-max (this project)
    • File .qwen/settings.json created with {"model": {"name": "qwen3.7-max", "baseUrl": ""}}
  • Result: PASS

Test 5: /model --global <model-id> (direct set)

  • Command: /model --global qwen3.6-plus
  • Expected: Confirmation shows Model: qwen3.6-plus (global) and writes to global settings.
  • Actual:
    • Confirmation: Model: qwen3.6-plus (global)
    • File ~/.qwen/settings.json updated with "model": {"name": "qwen3.6-plus", "baseUrl": ""}
    • Header bar updated to show qwen3.6-plus as the active model.
  • Result: PASS

Test 6: Mutual exclusion of --project and --global

  • Command: /model --project --global qwen3.7-max
  • Expected: Error message rejecting both flags.
  • Actual: Cannot use both --project and --global. Choose one scope flag.
  • Result: PASS

Test 7: Invalid model with --project

  • Command: /model --project qwen3-coder-plus (not in configured models)
  • Expected: Error listing available models.
  • Actual: Error message shown with full list of available models.
  • Result: PASS

Headless (non-interactive) Tests

Test 8: /model in non-interactive mode

  • Output: Current model: qwen3.7-max followed by help text mentioning both --project and --global flags.
  • Result: PASS

Test 9: /model --project in non-interactive mode (no model-id)

  • Output: Current model: qwen3.7-max -- correctly shows current model instead of trying to open a dialog.
  • Result: PASS

Test 10: /model --fast --project in non-interactive mode (no model-id)

  • Output: Current fast model: qwen3.7-max -- correctly shows current fast model.
  • Result: PASS

Code Review Observations

  1. Scope flag parsing (parseScopeFlags): Robust regex-based parsing that handles flags in any position. The mutual exclusion check is properly enforced before any side effects.

  2. Untrusted workspace guard: --project is correctly rejected when settings.isTrusted is false, preventing silent no-ops where workspace settings would be ignored during merge.

  3. persistScopeSpread helper: Clean spread-object pattern that conditionally adds persistScope to dialog returns only when a scope flag is present, preserving backward compatibility.

  4. ModelDialog integration: The resolvePersistScope() function correctly falls back to SettingScope.User when persistScope === 'workspace' but the workspace is untrusted, providing a safe degradation path.

  5. i18n: All user-visible strings (scope suffixes, dialog titles, error messages) go through t() for translation.

  6. Backward compatibility: /model without scope flags produces identical behavior to the pre-PR version -- no persistScope in dialog returns, no scope suffix in confirmations.


Verdict

All 10 E2E tests PASS. All 380 unit tests PASS (67 modelCommand + 313 config).

The implementation is solid: scope flags are correctly parsed, mutually exclusive, persisted to the right settings files, and the UI reflects the scope in dialog titles and confirmation messages. The untrusted-workspace guard and backward compatibility are both properly handled.


Chinese Translation

E2E Tmux 测试报告 -- PR #6060

测试环境

  • 操作系统: Ubuntu (Linux x86_64, 内核 7.0.0-22)
  • CLI: npm run dev (tsx 开发模式,无需完整构建)
  • Node: v22.22.2
  • 分支: dive/muti-model (提交 2ccb61505)
  • 沙箱: 已禁用 (QWEN_SANDBOX=false)

单元测试结果

测试套件 测试数 通过 失败
modelCommand.test.ts 67 67 0
config.test.ts 313 313 0

所有 11 个新增的作用域标志测试均通过。

Tmux 交互式 E2E 测试

测试 命令 结果
CLI 启动 npm run dev PASS
/model --project 对话框 标题显示 "Select Model (this project)" PASS
/model --global 对话框 标题显示 "Select Model (global)" PASS
/model --project <model> 确认信息含 "(this project)",写入项目级设置 PASS
/model --global <model> 确认信息含 "(global)",写入全局设置 PASS
--project --global 互斥 报错 "Cannot use both" PASS
无效模型 + --project 正确显示可用模型列表 PASS

无头模式测试

测试 结果
/model 非交互模式 PASS -- 正确显示当前模型和帮助文本
/model --project 无 model-id PASS -- 显示当前模型
/model --fast --project 无 model-id PASS -- 显示当前快速模型

结论

全部 10 个 E2E 测试通过,全部 380 个单元测试通过 (67 modelCommand + 313 config)。

实现质量良好:作用域标志解析正确、互斥检查到位、持久化到正确的设置文件、UI 正确反映作用域信息。不受信任工作空间的保护和向后兼容性均处理妥当。


persistSetting(settings, 'voiceModel', modelName);
persistSetting(settings, 'voiceModel', modelName, scopeOverride);
return {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The scopeSuffix ternary (persistScope === 'workspace' ? t(' (this project)') : persistScope === 'user' ? t(' (global)') : '') appears 6 times across modelCommand.ts and ModelDialog.tsx. A future scope addition (e.g., "team") would require updating all 6 sites — easy to miss one.

Suggested change
return {
const scopeSuffix =
scopeOverride === SettingScope.Workspace
? t(' (this project)')
: scopeOverride === SettingScope.User
? t(' (global)')
: '';

Consider extracting a shared helper like formatScopeSuffix(scope: 'workspace' | 'user' | undefined): string used by both files.

— qwen3.7-max via Qwen Code /review

});
});
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The new "scope flags" describe block covers dialog returns, fast-model direct-set, main-model direct-set, untrusted workspace, and no-flag default — good coverage of the happy path. However, several specific code paths in the diff remain untested:

  1. Voice model direct-set with scope (/model --project --voice <model-id>) — the persistSetting(settings, 'voiceModel', modelName, scopeOverride) call at modelCommand.ts:~503 is never exercised with SettingScope.Workspace.
  2. Vision model direct-set with scope (/model --project --vision <model-id>) — same gap at modelCommand.ts:~700.
  3. Inline prompt + scope flag rejection (/model --project qwen-max explain this) — the error path at modelCommand.ts:~780 is untested.
  4. Mutual exclusivity (/model --project --global) — the rejection at modelCommand.ts:~410 is untested.
  5. The switchMainModel scope test asserts setValue(scope, 'model.name', ...) but not the companion model.baseUrl tombstone or security.auth.selectedType writes at the same scope.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-bot added category/cli Command line interface and interaction priority/P2 Medium - Moderately impactful, noticeable problem scope/model-switching Model selection and switching scope/settings Settings and preferences type/feature-request New feature or enhancement request labels Jul 1, 2026
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @Alex-ai-future!

Template looks good ✓

Direction: Clearly aligned. Per-project model persistence is a real user need — different projects having different cost/complexity requirements is a common scenario. Claude Code's CHANGELOG has direct precedent: "selections now persist across restarts even when the project pins a different model, and the startup header shows when the active model comes from a project or managed-settings pin." This is exactly the kind of quality-of-life improvement users notice and appreciate.

Approach: The core feature is well-structured — threading persistScope through the existing dialog pipeline is the right call, and the backward compatibility is clean (no flag = current behavior). The mutual exclusion check, untrusted-workspace guard, and i18n coverage are all handled properly.

One concern before diving into code review: the diff carries significant formatting-only changes in unrelated files (~8 files including config.ts, mcp-transport-pool.test.ts, channel-loop.md, feishu/adapter.test.ts, service-worker.ts, dialogManager.ts). These are prettier reformatting of union types and line breaks that have nothing to do with the scope flag feature. They inflate the diff and make review harder. Could these be split into a separate PR (or dropped if prettier will handle them on the next format pass)?

Moving on to code review. 🔍

中文说明

感谢 PR!

模板完整 ✓

方向:明确对齐。按项目持久化模型选择是真实的用户需求——不同项目有不同的成本/复杂度需求。Claude Code 的 CHANGELOG 有直接先例:"selections now persist across restarts even when the project pins a different model"。这类体验改善用户很在意。

方案:核心功能结构良好——将 persistScope 穿入现有对话框管线是正确的做法,向后兼容也很干净(不带标志 = 当前行为)。互斥检查、不受信任工作空间保护、i18n 覆盖都处理得当。

一个顾虑:diff 中包含大量无关文件的纯格式化改动(约 8 个文件,包括 config.tsmcp-transport-pool.test.tschannel-loop.md 等)。这些都是 prettier 对联合类型和换行的重排,与 scope 标志功能无关,增大了 diff 体积并增加了审查难度。能否拆成单独 PR(或等下次 prettier 格式化时自动处理)?

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal for per-project model persistence: I would have added --project and --global flags to modelCommand, parsed them out before the existing arg processing, mapped them to SettingScope.Workspace/SettingScope.User, and threaded the scope through the persistSetting() call. The ModelDialog would need a prop to override the default scope, and the confirmation message would need a suffix showing which scope was used.

The PR's approach matches this almost exactly. parseScopeFlags() strips the flags from args, resolveScope() maps to SettingScope, persistScopeSpread() carries the scope through the dialog pipeline, and resolvePersistScope() in ModelDialog handles the untrusted-workspace fallback. Clean, minimal, and correct.

No critical blockers found. The untrusted-workspace guard, mutual exclusion check, and backward compatibility are all properly handled.

One minor note: the scopeSuffix ternary (persistScope === 'workspace' ? ' (this project)' : persistScope === 'user' ? ' (global)' : '') is duplicated in 5 places across handleModelSwitchSuccess, ModelDialog (voice, fast, vision, title). A small getScopeSuffix(scope) helper would reduce this, but it's a 3-line extraction — not a blocker.

Formatting churn (flagged in Stage 1): ~8 files with unrelated prettier reformatting still present in the diff. Not a blocker but adds noise to review.

Tmux Test Results

Unit Tests

All 67 tests in modelCommand.test.ts pass, including all 11 new scope flag tests.

 ✓ src/ui/commands/modelCommand.test.ts (67 tests) 141ms

 Test Files  1 passed (1)
      Tests  67 passed (67)

Headless E2E Tests

Test 1: /model --project --global qwen3.7-max (mutual exclusion)

$ QWEN_SANDBOX=false npm run dev -- -p '/model --project --global qwen3.7-max'
> node scripts/dev.js -p /model --project --global qwen3.7-max

Cannot use both --project and --global. Choose one scope flag.

✅ PASS — mutual exclusion enforced correctly.

Test 2: /model (backward compatibility, no flags)

$ QWEN_SANDBOX=false npm run dev -- -p '/model'
> node scripts/dev.js -p /model

Current model: qwen3.7-max
Use "/model <model-id>" to switch models, "/model --fast <model-id>" to set the fast model,
"/model --project <model-id>" to persist to project settings, or "/model --global <model-id>"
to persist to user settings.

✅ PASS — default behavior unchanged, help text surfaces new flags.

Test 3: /model --project (non-interactive, no model-id)

$ QWEN_SANDBOX=false npm run dev -- -p '/model --project'
> node scripts/dev.js -p /model --project

Current model: qwen3.7-max
Use "/model <model-id>" to switch models, ...

✅ PASS — shows current model, doesn't try to open dialog in headless mode.

Test 4: /model --global qwen3.7-max and /model --project qwen3.7-max (direct set)

$ QWEN_SANDBOX=false npm run dev -- -p '/model --global qwen3.7-max'
> node scripts/dev.js -p /model --global qwen3.7-max

Model 'qwen3.7-max' is not available for auth type 'openai'.

⚠️ SKIP — model not configured for the current auth type in this CI environment. Flag parsing works (the error is from model availability, not flag handling). Collaborator @DragonnZhang's interactive E2E report above confirms direct set works with correct scope suffix and file persistence.

Summary

Test Result
Unit tests (67) ✅ All pass
Scope flag unit tests (11) ✅ All pass
--project --global mutual exclusion ✅ Pass
/model backward compatibility ✅ Pass
/model --project headless ✅ Pass
/model --global <model> direct set ⚠️ Skip (no configured models)
Interactive dialog + persistence ✅ Pass (per collaborator E2E report)
中文说明

代码审查

独立方案与 PR 实现基本一致:添加 --project/--global 标志,解析后映射到 SettingScope,穿入对话框管线,确认信息带 scope 后缀。实现干净、最小化、正确。

未发现关键阻塞问题。 不受信任工作空间保护、互斥检查、向后兼容均处理正确。

小建议:scopeSuffix 三元表达式在 5 处重复,可提取为 getScopeSuffix() 辅助函数,但非阻塞。

格式化改动(Stage 1 已提出):约 8 个无关文件的 prettier 重排仍在 diff 中,增加审查噪音。

Tmux 测试结果

  • 67 个单元测试全部通过(含 11 个新增 scope 标志测试)
  • 互斥检查 ✅、向后兼容 ✅、无头模式 ✅
  • 直接设置因 CI 环境模型未配置跳过,协作者 @DragonnZhang 的交互式 E2E 报告已确认功能正确

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

Going back to my independent proposal: I would have done essentially the same thing — add two flags, parse them early, map to SettingScope, thread through the dialog pipeline, show a scope suffix in the confirmation. The PR matches this approach and adds thoughtful extras (untrusted workspace guard, mutual exclusion, i18n for 5 locales).

This solves a problem users actually hit: switching models in one project affecting all others. The fix is minimal and non-breaking — no flag means current behavior, which is exactly right for an additive feature.

The code is straightforward. parseScopeFlags is a clean regex strip, resolveScope is a one-liner null coalesce, persistScopeSpread is the right pattern for optional prop spreading. No over-abstraction, no unnecessary indirection. The scopeSuffix duplication across 5 places is a minor smell but it's 3 lines each — extracting a helper would save maybe 10 lines total, not worth blocking on.

The tmux results confirm what the unit tests already showed: flag parsing works, mutual exclusion fires, backward compatibility is preserved. The collaborator's interactive E2E report confirms the full dialog-to-persistence flow works end-to-end with correct scope suffixes and file writes.

The formatting churn in ~8 unrelated files is the one thing I'd ask to clean up — it makes future git blame noisier and inflates the diff. Either split into a separate PR or drop those changes (prettier will handle them on the next npm run format).

Ships the feature cleanly. Approving. ✅

中文说明

总结

回到独立方案:我基本会做同样的事——加两个标志、解析、映射到 SettingScope、穿入管线、确认信息带 scope 后缀。PR 与此一致,并额外加入了不受信任工作空间保护、互斥检查、5 种语言 i18n。

解决了用户实际遇到的问题:一个项目切换模型影响所有项目。改动最小且非破坏性——不带标志则行为不变。

代码直白:parseScopeFlags 干净的正则提取,resolveScope 一行空值合并,persistScopeSpread 正确的可选 prop 模式。scopeSuffix 在 5 处重复是小问题但每处只有 3 行,不值得阻塞。

Tmux 测试确认标志解析正确、互斥生效、向后兼容。协作者的交互式 E2E 报告确认完整流程正确。

唯一建议:清理约 8 个无关文件的格式化改动——拆成单独 PR 或丢弃(prettier 会在下次 format 时处理)。

功能实现干净,批准合并 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅ The formatting churn in unrelated files is minor — consider splitting or dropping those on a future format pass.

@wenshao

wenshao commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

✅ Local build & real-binary verification — PR #6060 (/model --project / --global scope flags)

I built this PR from source and verified it against the real compiled qwen binary (not just unit tests). Recommendation: mergeable. All CI-gate checks pass and the security behavior in the title (reject --project when the workspace is untrusted) works end-to-end. A few non-blocking nits are listed at the bottom.

Verified at PR head 2ccb615 (branch is 5 commits behind origin/main 8d5e47a). Net feature diff = 21 files; the two-dot diff looks huge only because the branch predates recent main refactors — not this PR's doing.

1. What it does

Adds --project (→ workspace/project settings) and --global (→ user settings) scope flags to /model (and its --fast/--voice/--vision sub-modes and the model dialog), so a model choice can be pinned per-project or globally. The headline fix: /model --project is rejected when the workspace is untrusted, because untrusted workspace settings are dropped on merge — the write would land on disk but silently never take effect, with a misleading “(this project)” confirmation. (Default no-flag behavior is unchanged; getModelProvidersOwnerScope already never returns Workspace when untrusted, so only explicit --project could hit the footgun.)

2. CI-gate checks — all green

Check Command Result
Unit (command) vitest run modelCommand.test.ts 67/67 pass
Unit (config) vitest run config.test.ts 313/313 pass
i18n npm run check-i18n ✅ All checks passed
Typecheck npm run typecheck (all workspaces) 0 errors
Lint eslint on all changed files clean
Format node scripts/lint.js --prettier (exact CI cmd) pass
Build npm run build exit 0, binary runs (--version → 0.19.3)

3. Real-binary TUI verification (isolated HOME, folder-trust configured, tmux)

# Scenario Observed
A untrusted ws + /model --project qwen-max ✕ Workspace is untrusted; run /trust first or use --global. — and no .qwen/ dir or settings file was created in the workspace
B /model --project --global ✕ Cannot use both --project and --global. Choose one scope flag.
C untrusted ws + /model --global Dialog opens titled “Select Model (global)”; selecting → Using runtime model: qwen3.5-plus (global) and model.name written to user settings — proving the block is scoped to --project only
D trusted ws + /model --project Dialog opens titled “Select Model (this project)”; selecting → (this project) confirmation and model.name written to the workspace .qwen/settings.json (feature’s core purpose)

Scenario A is the exact behavior in the PR title, confirmed on the real binary; the negative-control (no file written on rejection) also holds.

4. Non-blocking nits (for your judgment, none block merge)

  1. Unrelated formatting churn. packages/core/src/config/config.ts, config.test.ts, and tools/mcp-transport-pool.test.ts are prettier-3.6.1 reformatting only (union-type wrapping), no semantic change, swept in by a local pre-commit run. Zero CI risk (the CI “prettier” step is prettier --write ., which never fails on drift), but it adds diff noise + merge-conflict surface. Consider dropping these files from the PR.
  2. Two validation branches lack unit tests: the --project --global mutual-exclusion and the “scope flag + inline prompt” rejection. I confirmed the former via E2E (Scenario B) and the latter by code inspection (correctly placed after model-availability validation), but adding two small unit tests would lock them in.
  3. Latent suffix mismatch in ModelDialog.resolvePersistScope. When persistScope==='workspace' but untrusted, the dialog silently falls back to User scope yet still shows “(this project)”. This path is currently unreachable (the command hard-rejects that combo before opening any dialog), so it’s harmless defensive code — but if a future caller ever opens the dialog with persistScope:'workspace' while untrusted, the suffix would mislead. Either drop the fallback (command already guards) or make the suffix reflect the resolved scope.
  4. Branch is 5 commits behind origin/main. Worth updating before merge so CI runs against current trunk.
  5. fr.js / ja.js don’t translate the updated /model description string (they add the new suffix/persist keys but not the reworded description). Runtime falls back to English for those two locales; check-i18n passes. Cosmetic.

Verdict

Correct, well-tested, and the security fix works on the real binary. Safe to merge once you decide on nit #1 (formatting churn) and, ideally, a branch update (nit #4).

🇨🇳 中文版(点击展开)

✅ 本地构建 + 真实二进制验证 —— PR #6060/model --project / --global 作用域开关)

我从源码构建了本 PR,并用真实编译出的 qwen 二进制(不只是单测)做了端到端验证。结论:可以合并。 所有 CI 门禁检查通过,标题所述的安全行为(工作区不受信任时拒绝 --project)端到端可用。下方列了几条不阻塞合并的小建议。

验证基于 PR head 2ccb615(该分支落后 origin/main 8d5e47a 共 5 个提交)。真实功能改动 = 21 个文件;two-dot diff 看起来很大只是因为分支早于 main 最近的重构,与本 PR 无关。

1. 功能

/model(及其 --fast/--voice/--vision 子模式和模型对话框)新增 --project(→ 工作区/项目设置)和 --global(→ 用户设置)作用域开关,从而把模型选择固定到「按项目」或「全局」。核心修复:工作区不受信任时拒绝 /model --project——因为不受信任的工作区设置在合并时会被丢弃,写入虽落盘却永远不生效,还会给出误导性的「(当前项目)」确认。(无开关的默认行为不变;getModelProvidersOwnerScope 在不受信任时本就不会返回 Workspace,所以只有显式 --project 才会踩这个坑。)

2. CI 门禁检查 —— 全绿

检查 命令 结果
单测(命令) vitest run modelCommand.test.ts 67/67 通过
单测(config) vitest run config.test.ts 313/313 通过
i18n npm run check-i18n ✅ 全部通过
类型检查 npm run typecheck(全 workspace) 0 错误
Lint 对所有改动文件跑 eslint 干净
格式 node scripts/lint.js --prettier(CI 原命令) 通过
构建 npm run build exit 0,二进制可运行(--version → 0.19.3)

3. 真实二进制 TUI 验证(隔离 HOME、配置文件夹信任、tmux 驱动)

# 场景 观察结果
A 不受信任工作区 + /model --project qwen-max ✕ Workspace is untrusted; run /trust first or use --global.——且工作区内未创建 .qwen/ 目录或设置文件
B /model --project --global ✕ Cannot use both --project and --global. Choose one scope flag.
C 不受信任工作区 + /model --global 对话框标题为 “Select Model (global)”;选中后 → Using runtime model: qwen3.5-plus (global)model.name 写入用户设置——证明拦截只针对 --project
D 受信任工作区 + /model --project 对话框标题为 “Select Model (this project)”;选中后 → (this project) 确认,model.name 写入工作区 .qwen/settings.json(功能核心目的)

场景 A 就是 PR 标题所述行为,在真实二进制上确认;拒绝时不落盘的反向对照也成立。

4. 不阻塞合并的小建议(供你判断)

  1. 无关的格式化改动。 packages/core/src/config/config.tsconfig.test.tstools/mcp-transport-pool.test.ts 只是 prettier 3.6.1 的重新排版(联合类型换行),无语义变化,是本地 pre-commit 顺带带进来的。对 CI 无风险(CI 的 prettier 步骤是 prettier --write .,永不因格式漂移失败),但增加了 diff 噪声和合并冲突面。建议把这几个文件从 PR 中移除。
  2. 两个校验分支缺单测: --project --global 互斥、以及「作用域开关 + 内联 prompt」的拒绝。前者我用 E2E 确认(场景 B),后者用代码审查确认(正确地放在模型可用性校验之后),但补两个小单测能把它们固化下来。
  3. ModelDialog.resolvePersistScope 存在潜在的后缀不一致。persistScope==='workspace' 但不受信任时,对话框会静默回退到 User 作用域,却仍显示「(当前项目)」。该路径当前不可达(命令在打开任何对话框前就硬拒绝了该组合),属无害的防御性代码——但若将来有调用方在不受信任时用 persistScope:'workspace' 打开对话框,后缀就会误导。建议要么去掉这个回退(命令已把关),要么让后缀反映真正解析出的作用域。
  4. 分支落后 origin/main 5 个提交。 合并前最好更新分支,让 CI 跑在最新 trunk 上。
  5. fr.js / ja.js 没有翻译更新后的 /model 描述串(只加了新的后缀/persist 键,没改重写后的描述)。这两种语言运行时回退到英文;check-i18n 通过。仅外观问题。

结论

正确、测试充分,安全修复在真实二进制上生效。可以安全合并,只需你就第 1 条(格式化噪声)拿主意,并最好更新一下分支(第 4 条)。

🤖 Verified locally with a source build + real-binary tmux E2E. Full CI-gate suite + 4 end-to-end scenarios above.

@wenshao wenshao requested a review from pomelo-nwu July 1, 2026 19:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

category/cli Command line interface and interaction priority/P2 Medium - Moderately impactful, noticeable problem scope/model-switching Model selection and switching scope/settings Settings and preferences type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(settings): decouple default model from project model — per-project model.name support

6 participants