From 84c9b35f1db38016773e8be0a1681911e379acc4 Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 18 Jul 2026 21:34:51 +0800 Subject: [PATCH] feat: unify external extension conflict management --- .../external-ai-work-sources-design.md | 7 +- docs/architecture/product-architecture.md | 2 +- .../opencode-extension-compatibility-plan.md | 52 +++- .../product-architecture-evolution-plan.md | 6 +- src/apps/cli/src/actions.rs | 115 +++++--- src/apps/cli/src/modes/chat.rs | 5 +- src/apps/cli/src/modes/chat/capabilities.rs | 4 +- src/apps/cli/src/modes/chat/commands.rs | 84 +++--- .../cli/src/modes/chat/external_review.rs | 215 ++++++++++++--- .../cli/src/modes/chat/external_sources.rs | 54 ++-- src/apps/cli/src/modes/chat/input.rs | 14 +- src/apps/cli/src/modes/chat/selection.rs | 53 +++- src/apps/cli/src/modes/chat/tests.rs | 145 ++++++++-- src/apps/cli/src/ui/agent_selector.rs | 255 ++++++++++++++++-- src/apps/cli/src/ui/chat/mouse.rs | 8 +- src/apps/cli/src/ui/chat/popups.rs | 38 ++- src/apps/cli/src/ui/chat/state.rs | 7 +- src/apps/cli/src/ui/command_menu.rs | 4 +- src/apps/cli/src/ui/command_palette.rs | 8 +- src/apps/cli/src/ui/startup.rs | 47 ++-- .../assembly/core/src/external_subagents.rs | 7 +- .../services-integrations/src/script_tool.rs | 58 +++- .../components/ExternalSourcesConfig.scss | 5 + .../components/ExternalSourcesConfig.test.tsx | 220 ++++++++++++++- .../components/ExternalSourcesConfig.tsx | 204 ++++++++++---- .../en-US/settings/external-sources.json | 31 ++- .../zh-CN/settings/external-sources.json | 31 ++- .../zh-TW/settings/external-sources.json | 31 ++- 28 files changed, 1383 insertions(+), 327 deletions(-) diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index edf0a41e9..f6aa96697 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -312,8 +312,11 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 3. 内置、MCP 与外部同名 Tool 使用包含候选身份和内容版本的指纹;选择前保留已有本地实现,不按注册顺序静默覆盖。 候选集合由已识别定义而非成功加载结果计算;候选更新、暂不可用或删除后重新选择,已选外部实现失效时保持 unavailable,不静默回退同名本地实现。其他无关 Tool 和生态不受影响。 -4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,`/external-tools` - 以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新。普通聊天输入和无关会话不等待用户处理。 +4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,通用 `/tools` + 入口在“外部 AI 应用”分组中以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新;Agent 相关能力统一复用 + `/agents`,在同一列表中以文字区分主 Agent、Subagent 与外部 AI 应用,不另建 `/subagents` 或 `external-*` 命令。 + 普通聊天输入和无关会话不等待用户处理;活动 turn 期间入口仍可查看和管理,但主 Agent 切换单独禁用并说明原因, + 不改变正在执行的 session。 5. 每个 target 使用一个持久 Node worker,支持 load/invoke、`AbortSignal` 取消、500 ms 宽限后的 target 级硬终止、 30 秒请求期限和 dispose;产品配置的更短外层期限丢弃调用 future 时也会终止 worker,并在终止完成前保持 target 串行许可。输出限制为 1 MiB,协议帧限制为 8 MiB。稳定删除、抑制或撤销先撤下新调用;更新加载 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 5f5029a11..1c60ddade 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -327,7 +327,7 @@ flowchart LR | 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| | Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool 和 Subagent 的统一来源快照、审批、冲突选择与诊断;Skills 场景和设置列表显示已发现 Skill 的生态来源与覆盖结果 | 仅本地执行域支持当前三条可执行纵向切片;Skill 仍使用独立 Registry,不据来源展示宣称已并入外部来源协调器。受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并通过 `/external-tools`、`/external-agents` 消费与 Desktop 相同的审批、冲突和刷新状态;Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行。本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli` 并消费同一 Runtime Parts/SDK;主会话的恢复、转录、本地分支、用量报告生成与精确结算走 SDK,远程分支明确不支持。TUI 用量卡片持久化、快照及 Peer Host/ACP 持久化维护缺口由单一 Core 兼容门面转发;本切片不改变扩展执行边界 | +| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并在通用 `/tools` 与 `/agents` 入口按文字分组消费与 Desktop 相同的审批、冲突和刷新状态;`/agents` 同时承载主 Agent 切换、Subagent 管理和“外部 AI 应用”,不再注册 `/subagents` 或 `external-*` 平行命令。活动 turn 期间仍可查看和管理,只有主 Agent 切换被禁用。Skill 列表显示来源,模式配置按实际选择结果说明覆盖来源 | 已批准的 standalone Tool 进入现有 Tool Runtime;已批准的外部 Subagent 只支持 fresh single-run。CLI/TUI 不解析生态文件、不启动第二套 worker/Agent owner;非交互入口和 Remote 未接入时不得借本机 TUI 路径代执行。本地 Agent 与 Peer Host 路径选择 `DeliveryProfile::Cli` 并消费同一 Runtime Parts/SDK;主会话的恢复、转录、本地分支、用量报告生成与精确结算走 SDK,远程分支明确不支持。TUI 用量卡片持久化、快照及 Peer Host/ACP 持久化维护缺口由单一 Core 兼容门面转发;本切片不改变扩展执行边界 | | HarmonyOS PC 原生 CLI/TUI | 未来平台目标,当前未实现 | 目标、问题和风险见平台规约;具体适配另立专题,HAP、手机 Remote App 与远端代执行均不替代 | | HarmonyOS PC GUI | 完整 HarmonyOS PC 支持的另一目标形态,当前未实现 | 与 CLI/TUI 共享稳定能力和 Runtime 语义,但独立设计宿主、界面与发布验证;Web、Remote 或现有 Tauri Desktop 均不能替代 | | HarmonyOS 手机 Remote App | `src/apps/mobile/harmonyos` 是 phone-only ArkTS 远程入口,不持有本地 Rust Agent Runtime | 保持当前能力并按移动端专题独立演进;本轮不提前设计移动 Runtime/TUI/GUI,也不能据此宣称 HarmonyOS PC 本地能力 | diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 10ee8c5eb..34fea4327 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -160,8 +160,9 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo ### 4.2 产品与决策语义 - Desktop 在“外部 AI 应用”中显示来源、文件、工作目录、工具名和直接文件/网络/环境/进程能力,并明确提示当前 worker - 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:状态栏只做一次非阻塞提醒,`/external-tools` 提供静态预览以及 - `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话。 + 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:状态栏只做一次非阻塞提醒,通用 `/tools` 入口以“外部 AI 应用” + 分组提供静态预览以及 `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话,不再注册平行的 + `external-*` 命令。 - 首次启用键由“来源限定 target + 执行域 + runtime + 能力集合”组成。纯内容更新且能力集合不变时复用已批准 结果;能力、runtime 或执行域扩大时重新确认。用户选择保持停用后,同一内容版本不再主动询问;来源内容更新后 才形成新 decision key。Desktop 仍允许用户主动重新审核,避免“一次拒绝后永久不可恢复”。 @@ -233,8 +234,8 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 旧绑定事实,若执行时配置已漂移则安全失败,不静默切换模型。 - fresh Task 在进入调度前固定不可变 runtime generation,并以 lease 保持到完成、取消、超时或提交失败;来源稳定 删除、显式停用或抑制会立即阻止新调用,短暂读取失败最多在有界窗口内沿用 exact last-valid。 -- Desktop 的“外部 AI 应用”设置和 TUI `/external-agents` 使用同一 generation/revision 校验的非阻塞审批与冲突 - 操作;同一工作区的 Agent 决策串行提交,完成后刷新权威状态并说明具体对象。冲突候选在选择前原位展示模型、 +- Desktop 的“外部 AI 应用”设置和 TUI `/agents` 中的“外部 AI 应用”选项使用同一 generation/revision 校验的 + 非阻塞审批与冲突操作;同一工作区的 Agent 决策串行提交,完成后刷新权威状态并说明具体对象。冲突候选在选择前原位展示模型、 工具、执行域、安全来源标签、兼容影响和恢复动作,一次点击才原子完成“选择并批准”。Agents 场景只显示已激活的 只读 `External · provider`、`Single-run` 投影,并可跳转到统一管理入口;外部 prompt、绝对用户路径和内部行为摘要 不进入 IPC 或普通列表摘要。 @@ -252,9 +253,12 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 本轮不新增扩展类型,只修复会误导用户或掩盖真实故障的两处问题,并校正文档事实: -- `NodeScriptToolRuntime` 在共享外部 Tool 运行时首次初始化时解析 Node.js 可执行文件,并在当前进程内持有结果。 - 本轮保持该生命周期,不增加运行时热替换;首次初始化时未找到 Node.js 后,Desktop 与 TUI 必须明确提示安装或修复后 - 重启 BitFun。刷新只重新发现来源,不能描述为会重新读取进程 `PATH`。运行时状态不得把尚未实际启动的可执行文件写成“已验证”。 +- `NodeScriptToolRuntime` 在共享外部 Tool 运行时首次初始化时解析 Node.js 可执行文件,并在当前进程内持有成功结果; + 只有缓存仍为空时,后续可用性检查或加载才重新执行当前进程可见范围内的 `which(node)`,不会替换已持有路径、worker + 或活动调用。首次未找到 Node.js 后,Desktop 与 TUI 只提示安装或修复、刷新,以及可继续使用其他功能;当前宿主未记录 + “安装后已经刷新但仍不可见”的独立证据,因此不主动建议重启,也不提供自动重启或“立即重启”动作,更不能中断活动 session。 + 刷新会重新发现来源并触发运行环境可用性检查,但不能承诺继承父进程后续收到的环境变量变化;运行时状态不得把 + 尚未实际启动的可执行文件写成“已验证”。 - Subagent owner 读取 BitFun 模型配置失败时必须 fail closed,并生成与“模型不存在”不同的通用诊断。GUI/TUI 提示用户先确认 BitFun 模型设置能够正常读取和保存,再刷新;日志只保留经过脱敏的失败阶段和错误类别。不得用 `AIConfig::default()` 把配置服务异常转换成候选模型不匹配,也不得把原始错误或绝对配置路径投影到普通快照。临时故障期间不得改写已持久化的 @@ -308,7 +312,39 @@ Command、Tool、Subagent、MCP 以及未来可执行扩展默认把 BitFun 原 “等待选择”及原因展示,不能从列表消失。该原则由各能力 owner 的独立冲突契约实现,不复用 Skill 的固定优先级, 也不在 PR4 修改 PR1—PR3 已有执行路径。 -## 7. 暂停条件 +## 7. PR5:同名扩展的选择可见性与统一入口 + +PR5 不新增扩展类型或运行能力,只补齐 Command、Tool、Subagent 已有冲突决策的管理闭环,并纠正 TUI 信息架构: + +- 三类 owner 继续维护各自 DTO、冲突指纹和选择持久化,主体逻辑不按 OpenCode 等生态 ID 分支。候选列表由 owner 输出; + 存在 BitFun 原生/本地候选时固定在首位,其余外部候选使用 adapter 已确定的稳定顺序。Skill Registry 的发现顺序和覆盖 + 关系完全不变,不能复用这里的交互式选择规则。 +- Desktop 同时显示待选择和已处理冲突,待选择项排在前面。每个候选明确标识“当前使用”“已选择但当前不可用”“未使用”或“可选”;已选择、 + 被覆盖以及 Subagent 的“保持不可用”状态都不会因决策完成而消失,用户可直接改选。候选或行为版本变化使冲突指纹变化后, + 旧选择失效并重新要求选择;没有变化时不重复打断。 +- 交互式 TUI 不提供 `/external-tools`、`/external-agents` 等平行命令。工具使用通用 `/tools` 入口,并在内容中以 + “外部 AI 应用”分组;Agent 相关能力统一进入 `/agents`,同一列表以文字区分主 Agent、Subagent 和外部 AI 应用, + 删除历史 `/subagents` 命令。待选择与当前选择分区显示,只有待选择项进入 + 状态栏提醒计数;编号操作绑定产生当前视图的快照和稳定 key,改选仍由 owner 做 generation/revision 校验。 + 活动 turn 期间 `/agents` 仍可查看和管理 Subagent/外部来源,只禁用主 Agent 切换,并在对应行和状态栏解释原因。 +- GUI/TUI 都保持原生异常语义:读取失败不假定变更成功,刷新失败保留已知可用内容并提示状态可能过期,存储异常使受影响 + 名称 fail closed,Remote 明确不支持且不回退本机。模型设置读取失败只要求修复设置并刷新,不把重启当作通用恢复动作。 +- 本轮不新增重启编排。Node.js 是当前唯一可能受宿主进程环境影响的扩展依赖,但现有宿主没有可靠的刷新前后证据, + 因此只建议安装或修复后刷新,并说明用户可以继续使用其他能力;扩展入口不得建议或触发重启,也不得打断执行中的 session。 + 未来若产品增加统一重启动作,必须由 session owner + 提供活动任务保护与恢复契约,默认允许“稍后”,不能由扩展模块自行判断。 +- Remote 工作区继续显示明确不支持;不加载本机配置代替远程配置。普通轮询仍为 5 秒,仅发现进行中使用 750 ms 有界轮询; + 本轮不增加 watcher、全局扫描、遥测平台或磁盘缓存,不改变 system prompt。 + +PR5 的退出条件是:三类已处理冲突在 GUI 中可见可改选;Tool/Subagent 在 TUI 中使用通用能力入口;原生候选顺序、 +待处理提醒去重、选择指纹失效、Remote 无回退和非强迫恢复文案均有聚焦测试。该切片不以统一入口为由构造通用 `ExtensionAsset` +或第二套选择状态。 + +TUI 命令命名属于产品契约。新增命令前必须先核对 BitFun 既有入口以及至少一个同类竞品的用户可见命令;能力已有对应 +入口时优先在原列表中通过分组、状态或二级选项表达来源差异。只有对象、生命周期或权限边界确实不同,并且复用入口会产生 +歧义时,才允许新增命令,并须在设计与测试中写明理由。adapter 名、provider ID 或“external”来源标签都不能单独构成新命令依据。 + +## 8. 暂停条件 出现以下情况时停止扩面并先修复架构: diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index 3b8607adc..854cdc1cd 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -93,8 +93,10 @@ App 保持不变。 2. standalone Tool 只加载受支持的单文件 JavaScript 子集,经来源确认后接入现有 Tool Runtime; 3. Subagent 安全子集经模型、工具和同名冲突确认后接入现有 Subagent owner,仅支持 fresh single-run。 -近期先修复已实现路径中的事实偏移和类型化失败:运行时依赖在当前进程首次检查时不可用时给出真实的重启建议;读取 BitFun -模型配置失败时阻止外部 Subagent 激活并显示独立原因,不能伪装成“请求模型不存在”。随后只有官方 import 型 tool、 +已实现路径已经收敛运行时依赖与配置失败:Node.js 在当前进程首次检查时不可用时,只提示修复、刷新和可继续使用其他功能; +当前宿主没有可靠的刷新前后证据,因此不主动建议或触发重启。读取 BitFun 模型配置失败时阻止外部 Subagent 激活并显示独立原因,不能伪装成 +“请求模型不存在”。同名冲突的待选择和当前选择保持可见,TUI 通过通用 `/tools` 与 `/agents` 入口按能力和来源分组; +`/agents` 同时容纳主 Agent、Subagent 和外部来源管理,不再创建 `/subagents` 或 `external-*` 平行命令。随后只有官方 import 型 tool、 package plugin、Hook 或 TUI contribution 的真实样例证明当前 owner/契约不足时,才增加对应的最小切片。原始 OpenTUI/Solid renderer、完整 package runtime 和 Remote 执行仍保持不支持。 diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 720d9e55c..1e70eaf2b 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -64,10 +64,8 @@ pub(crate) enum ActionHandler { Sessions, Skills, ReloadSkills, - Subagents, McpServers, - ExternalTools, - ExternalAgents, + Tools, AcpHelp, Init, History, @@ -192,11 +190,11 @@ static ACTION_SPECS: &[ActionSpec] = &[ }, ActionSpec { id: "switch_agent", - name: "Switch agent", + name: "Agents", aliases: &["/agents"], - description: "Switch agent mode", + description: "Switch modes and manage agents", contexts: BOTH, - availability: ActionAvailability::Idle, + availability: ActionAvailability::Always, handler: ActionHandler::OpenAgentSelector, default_bindings: &[], fallback_bindings: &[], @@ -340,21 +338,6 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, - ActionSpec { - id: "subagents", - name: "Subagents", - aliases: &["/subagents"], - description: "List and configure subagents", - contexts: BOTH, - availability: ActionAvailability::Always, - handler: ActionHandler::Subagents, - default_bindings: &[], - fallback_bindings: &[], - shortcut_field: None, - palette: palette("Prompt", false), - shortcut_label: None, - slash_on_startup: true, - }, ActionSpec { id: "mcp_servers", name: "MCP servers", @@ -371,32 +354,17 @@ static ACTION_SPECS: &[ActionSpec] = &[ slash_on_startup: true, }, ActionSpec { - id: "external_tools", - name: "External tools", - aliases: &["/external-tools"], - description: "Review compatible tools from external AI applications", - contexts: CHAT, - availability: ActionAvailability::Always, - handler: ActionHandler::ExternalTools, - default_bindings: &[], - fallback_bindings: &[], - shortcut_field: None, - palette: palette("Extensions", false), - shortcut_label: None, - slash_on_startup: false, - }, - ActionSpec { - id: "external_agents", - name: "External agents", - aliases: &["/external-agents"], - description: "Review compatible agents from external AI applications", + id: "tools", + name: "Tools", + aliases: &["/tools"], + description: "View tool sources and manage external tools", contexts: CHAT, availability: ActionAvailability::Always, - handler: ActionHandler::ExternalAgents, + handler: ActionHandler::Tools, default_bindings: &[], fallback_bindings: &[], shortcut_field: None, - palette: palette("Extensions", false), + palette: palette("Tools", false), shortcut_label: None, slash_on_startup: false, }, @@ -835,6 +803,40 @@ pub(crate) fn action_for_alias(alias: &str, context: ActionContext) -> Option<&' }) } +/// Stable behavior version used when a built-in slash action participates in a +/// persisted external-command conflict. Update an override only when that +/// action's user-visible behavior changes in a way that requires reconfirmation. +pub(crate) fn action_conflict_behavior_version(action_id: &str) -> &'static str { + match action_id { + "switch_agent" => "agents-management-v1", + _ => env!("CARGO_PKG_VERSION"), + } +} + +pub(crate) fn removed_management_command_hint( + alias: &str, + context: ActionContext, +) -> Option<&'static str> { + match (alias.to_ascii_lowercase().as_str(), context) { + ("/subagents", _) => Some( + "/subagents was removed. Open Agents from the command palette and choose Subagents.", + ), + ("/external-tools", ActionContext::Chat) => Some( + "/external-tools was removed. Open Tools from the command palette; external sources are shown in that view.", + ), + ("/external-agents", ActionContext::Chat) => Some( + "/external-agents was removed. Open Agents from the command palette and choose External AI applications.", + ), + ("/external-tools", ActionContext::Startup) => Some( + "/external-tools was removed. Start a session, then open Tools from the command palette.", + ), + ("/external-agents", ActionContext::Startup) => Some( + "/external-agents was removed. Start a session, then open Agents from the command palette and choose External AI applications.", + ), + _ => None, + } +} + pub(crate) fn slash_actions(state: ActionState) -> Vec { ACTION_SPECS .iter() @@ -1676,6 +1678,29 @@ mod tests { ); } + #[test] + fn extension_management_uses_capability_entries_instead_of_external_commands() { + let tools = action_for_alias("/tools", ActionContext::Chat).unwrap(); + assert_eq!(tools.handler, ActionHandler::Tools); + let agents = action_for_alias("/agents", ActionContext::Chat).unwrap(); + assert_eq!(agents.handler, ActionHandler::OpenAgentSelector); + assert_eq!(agents.description, "Switch modes and manage agents"); + assert!(agents.available(ActionState::chat(true, false))); + assert!(action_for_alias("/subagents", ActionContext::Chat).is_none()); + assert!(action_for_alias("/external-tools", ActionContext::Chat).is_none()); + assert!(action_for_alias("/external-agents", ActionContext::Chat).is_none()); + assert!( + removed_management_command_hint("/subagents", ActionContext::Chat) + .unwrap() + .contains("choose Subagents") + ); + assert!( + removed_management_command_hint("/external-tools", ActionContext::Startup) + .unwrap() + .contains("Start a session") + ); + } + #[test] fn no_config_uses_current_real_dispatch_defaults() { let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); @@ -1919,10 +1944,12 @@ mod tests { #[test] fn action_availability_is_enforced_at_the_dispatch_boundary() { - let switch_agent = action_by_id("switch_agent", ActionContext::Chat).unwrap(); + let agents = action_by_id("switch_agent", ActionContext::Chat).unwrap(); + let cycle_agent = action_by_id("cycle_agent", ActionContext::Chat).unwrap(); let exit = action_by_id("exit", ActionContext::Chat).unwrap(); - assert!(!switch_agent.available(ActionState::chat(true, false))); + assert!(agents.available(ActionState::chat(true, false))); + assert!(!cycle_agent.available(ActionState::chat(true, false))); assert!(exit.available(ActionState::chat(true, false))); } diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 37a7b9102..564fde17e 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -23,14 +23,15 @@ use bitfun_events::{AgenticEvent, ToolEventData}; use resize::ResizeRedrawState; use crate::actions::{ - action_by_id, action_for_alias, slash_actions, ActionContext, ActionHandler, ActionSpec, + action_by_id, action_conflict_behavior_version, action_for_alias, + removed_management_command_hint, slash_actions, ActionContext, ActionHandler, ActionSpec, ActionState, ResolvedKeymap, }; use crate::agent::runtime_client::CliAgentRuntimeClient; use crate::chat_state::ChatState; use crate::config::CliConfig; use crate::runtime::CliRuntimeContext; -use crate::ui::agent_selector::AgentItem; +use crate::ui::agent_selector::{AgentItem, AgentSelectorAction}; use crate::ui::chat::{ChatView, MouseGestureOutcome}; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use crate::ui::command_palette::PaletteAction; diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index 135a2c9cf..0e5d58dd0 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -310,7 +310,7 @@ impl ChatMode { if subagent_items.is_empty() { chat_state.add_system_message(if has_external_subagents { - "No locally manageable subagents found. External agents are read-only here; use /external-agents to review or change them." + "No locally manageable subagents found. Open Agents from the command palette to review imported agents." .to_string() } else { "No subagents found.".to_string() @@ -322,7 +322,7 @@ impl ChatMode { } fn handle_subagent_selector_action( - &self, + &mut self, action: SubagentSelectorAction, chat_view: &mut ChatView, chat_state: &mut ChatState, diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 126331b20..283d4ef0a 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -32,27 +32,29 @@ impl ChatMode { chat_view.set_status(Some(format!("Unknown action: {action_id}"))); return Ok(None); }; - if let Some(collision) = self.native_command_collision_for_action(action.id) { - self.remember_native_command_choice( - &collision, - &collision.native_candidate_id, - chat_view, - rt_handle, - ); - } else if let Some(reconfirmation) = builtin_command_reconfirmation( - action.id, - action.name, - &self.external_conflict_preferences(), - ) - .filter(|reconfirmation| !reconfirmation.confirmed) - { - self.remember_command_choice( - &reconfirmation.conflict_key, - &reconfirmation.candidate_id, - vec![reconfirmation.candidate_id.clone()], - chat_view, - rt_handle, - ); + if !action_opens_extension_management(action) { + if let Some(collision) = self.native_command_collision_for_action(action.id) { + self.remember_native_command_choice( + &collision, + &collision.native_candidate_id, + chat_view, + rt_handle, + ); + } else if let Some(reconfirmation) = builtin_command_reconfirmation( + action.id, + action.name, + &self.external_conflict_preferences(), + ) + .filter(|reconfirmation| !reconfirmation.confirmed) + { + self.remember_command_choice( + &reconfirmation.conflict_key, + &reconfirmation.candidate_id, + vec![reconfirmation.candidate_id.clone()], + chat_view, + rt_handle, + ); + } } self.dispatch_action( action, @@ -89,6 +91,9 @@ impl ChatMode { } let builtin_alias = format!("/{command_name}"); let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); + let nonmutating_management_subcommand = builtin_action.is_some_and(|action| { + action_opens_extension_management(action) && !arguments.trim().is_empty() + }); let mut external = self.external_command_projection(command_name); let authoritative_preferences = tokio::task::block_in_place(|| { rt_handle @@ -123,7 +128,7 @@ impl ChatMode { chat_view, rt_handle, ); - } else if qualifier == CommandQualifier::Builtin { + } else if qualifier == CommandQualifier::Builtin && !nonmutating_management_subcommand { self.remember_native_command_choice( &collision, &collision.native_candidate_id, @@ -131,7 +136,7 @@ impl ChatMode { rt_handle, ); } - } else if qualifier == CommandQualifier::Builtin { + } else if qualifier == CommandQualifier::Builtin && !nonmutating_management_subcommand { if let Some(reconfirmation) = builtin_reconfirmation .as_ref() .filter(|reconfirmation| !reconfirmation.confirmed) @@ -161,7 +166,7 @@ impl ChatMode { .is_some_and(|reconfirmation| !reconfirmation.confirmed); let unresolved_candidates = self.external_conflict_projections(command_name); let can_route_external_tool_review = builtin_action - .is_some_and(|action| action.handler == ActionHandler::ExternalTools) + .is_some_and(|action| action.handler == ActionHandler::Tools) && qualifier != CommandQualifier::External && (qualifier == CommandQualifier::Builtin || (external.is_none() @@ -172,7 +177,8 @@ impl ChatMode { return Ok(None); } let can_route_external_agent_review = builtin_action - .is_some_and(|action| action.handler == ActionHandler::ExternalAgents) + .is_some_and(|action| action.handler == ActionHandler::OpenAgentSelector) + && !arguments.trim().is_empty() && qualifier != CommandQualifier::External && (qualifier == CommandQualifier::Builtin || (external.is_none() @@ -246,10 +252,15 @@ impl ChatMode { ) { Ok(result) => Ok(result), Err(error) if error.to_string().contains("command not found") => { - chat_state.add_system_message(format!( - "Unknown command: {}\nUse /help or type / to see available commands", - parts[0] - )); + let message = removed_management_command_hint(parts[0], ActionContext::Chat) + .map(str::to_string) + .unwrap_or_else(|| { + format!( + "Unknown command: {}\nUse /help or type / to see available commands", + parts[0] + ) + }); + chat_state.add_system_message(message); Ok(None) } Err(error) => Err(error), @@ -591,18 +602,12 @@ impl ChatMode { ActionHandler::ReloadSkills => { self.reload_skills_from_disk(chat_view, chat_state, rt_handle); } - ActionHandler::Subagents => { - self.show_subagent_selector(chat_view, chat_state, rt_handle); - } ActionHandler::McpServers => { self.show_mcp_selector(chat_view, chat_state, rt_handle); } - ActionHandler::ExternalTools => { + ActionHandler::Tools => { self.handle_external_tool_review("", chat_view, chat_state, rt_handle); } - ActionHandler::ExternalAgents => { - self.handle_external_agent_review("", chat_view, chat_state, rt_handle); - } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); chat_view.set_status(Some( @@ -755,3 +760,10 @@ impl ChatMode { } } } + +fn action_opens_extension_management(action: &ActionSpec) -> bool { + matches!( + action.handler, + ActionHandler::Tools | ActionHandler::OpenAgentSelector + ) +} diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index b6f8cd45c..7cef55cb0 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -47,7 +47,10 @@ fn external_command_projections( source.record.execution_domain_id.as_str(), &entry.definition.name, [ - (native_candidate_id.as_str(), env!("CARGO_PKG_VERSION")), + ( + native_candidate_id.as_str(), + action_conflict_behavior_version(action.id), + ), ( external_candidate_id.as_str(), entry.definition.content_version.as_str(), @@ -106,7 +109,10 @@ fn external_command_projections( ) }) .collect::>(); - candidates.push((native_candidate_id.as_str(), env!("CARGO_PKG_VERSION"))); + candidates.push(( + native_candidate_id.as_str(), + action_conflict_behavior_version(action.id), + )); let conflict_key = native_command_conflict_key(execution_domain, &conflict.command_name, candidates); Some((action.id.to_string(), native_candidate_id, conflict_key)) @@ -283,7 +289,7 @@ fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static "Change the code to a single JavaScript file supported by BitFun, then refresh." } ExternalToolActivationState::RuntimeUnavailable { .. } => { - "Please install or repair Node.js, then restart BitFun." + "Install or repair Node.js, then refresh. You can continue without external JavaScript tools while the run environment is unavailable." } ExternalToolActivationState::LoadFailed { .. } => { "Refresh to retry. If it still fails, fix the source code or keep these tools disabled." @@ -394,12 +400,16 @@ fn external_tool_runtime_label(runtime: ExternalToolRuntimeKind) -> &'static str fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { let Some(snapshot) = snapshot else { - return "External tools\n\nBitFun has not finished checking external tools. Run /external-tools refresh and try again." + return "Tools\n\nBitFun and MCP\nBuilt-in tools are provided by BitFun. Use /mcps to manage MCP servers.\n\nExternal AI applications\nBitFun has not finished checking imported tools. Run /builtin:tools refresh and try again." .to_string(); }; let mut lines = vec![ - "External tools".to_string(), + "Tools".to_string(), String::new(), + "BitFun and MCP".to_string(), + "Built-in tools are provided by BitFun. Use /mcps to manage MCP servers.".to_string(), + String::new(), + "External AI applications".to_string(), "BitFun does not run external code while checking sources. Enabling tools runs their code with your user permissions and inherited environment variables. The code is not isolated by an OS sandbox, and processes it starts may keep running after cancellation." .to_string(), ]; @@ -490,10 +500,10 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - )); let mut commands = Vec::new(); if external_tool_can_enable(target.activation()) { - commands.push(format!("/external-tools enable {}", index + 1)); + commands.push(format!("/builtin:tools enable {}", index + 1)); } if external_tool_can_disable(target.activation()) { - commands.push(format!("/external-tools disable {}", index + 1)); + commands.push(format!("/builtin:tools disable {}", index + 1)); } if !commands.is_empty() { lines.push(format!(" Commands: {}", commands.join(" or "))); @@ -502,12 +512,23 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - } lines.push(String::new()); - lines.push("Name conflicts".to_string()); - let pending_conflicts = snapshot + lines.push("Name conflicts - needs a choice".to_string()); + let conflicts = snapshot .tool_conflicts .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) + .chain( + snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_some()), + ) .collect::>(); + let pending_count = conflicts + .iter() + .take_while(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let pending_conflicts = &conflicts[..pending_count]; if pending_conflicts.is_empty() { lines.push(" None".to_string()); } else { @@ -519,7 +540,7 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - )); for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { lines.push(format!( - " {}. {} - /external-tools choose {} {}", + " {}. {} - /builtin:tools choose {} {}", candidate_index + 1, candidate.display_name, conflict_index + 1, @@ -533,11 +554,57 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - } } + lines.push(String::new()); + lines.push("Current choices".to_string()); + let resolved_conflicts = &conflicts[pending_count..]; + if resolved_conflicts.is_empty() { + lines.push(" None".to_string()); + } else { + for (resolved_index, conflict) in resolved_conflicts.iter().enumerate() { + let conflict_index = pending_count + resolved_index; + lines.push(format!( + " {}. Tools named '{}':", + conflict_index + 1, + conflict.tool_name + )); + for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { + let status = if conflict.selected_candidate_id.as_deref() + == Some(candidate.candidate_id.as_str()) + { + let selected_external_unavailable = candidate.source.is_some() + && !snapshot.tools.iter().any(|tool| { + tool.definition.candidate_id() == candidate.candidate_id + && tool.activation == ExternalToolActivationState::Active + }); + if selected_external_unavailable { + "selected, currently unavailable" + } else { + "selected" + } + } else { + "not selected" + }; + lines.push(format!( + " {}. {} [{}] - /builtin:tools choose {} {}", + candidate_index + 1, + candidate.display_name, + status, + conflict_index + 1, + candidate_index + 1 + )); + } + lines.push( + " This choice is remembered until one of these tools changes. Choose another entry above to change it." + .to_string(), + ); + } + } + append_external_source_issues(&mut lines, snapshot, ExternalIssueSurface::Tools); lines.push(String::new()); lines.push( - "Use /external-tools refresh after editing, upgrading, or removing external tools." + "Use /builtin:tools refresh after editing, upgrading, or removing tools from an external AI application." .to_string(), ); lines.join("\n") @@ -713,12 +780,13 @@ fn external_agent_model_label(model: Option<&str>) -> &str { fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { let Some(snapshot) = snapshot else { - return "External agents\n\nBitFun has not finished checking external agents. Run /external-agents refresh and try again." + return "Agents\n\nExternal AI applications\nBitFun has not finished checking imported agents. Run /builtin:agents refresh and try again." .to_string(); }; let mut lines = vec![ - "External agents".to_string(), + "Agents".to_string(), String::new(), + "External AI applications".to_string(), "BitFun only reads supported settings while checking sources. Agent instructions stay hidden and are not added to the current agent. Once enabled, those instructions guide the selected model and may call the tools listed below. Before enabling, review the model, tools, and where the agent runs. BitFun asks again if the instructions, model, tools, or configuration sources change. Each use starts a new task; follow-up is not supported in this version." .to_string(), ]; @@ -787,11 +855,11 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) match agent.activation_state { ExternalSubagentActivationState::ApprovalRequired | ExternalSubagentActivationState::Declined => lines.push(format!( - " Command: /external-agents enable {}", + " Command: /builtin:agents enable {}", index + 1 )), ExternalSubagentActivationState::Active => lines.push(format!( - " Command: /external-agents disable {}", + " Command: /builtin:agents disable {}", index + 1 )), _ => {} @@ -800,16 +868,27 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) } lines.push(String::new()); - lines.push("Name conflicts".to_string()); + lines.push("Name conflicts - needs a choice".to_string()); let conflicts = snapshot .subagent_conflicts .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) + .chain( + snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_some()), + ) .collect::>(); - if conflicts.is_empty() { + let pending_count = conflicts + .iter() + .take_while(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let pending_conflicts = &conflicts[..pending_count]; + if pending_conflicts.is_empty() { lines.push(" None".to_string()); } else { - for (conflict_index, conflict) in conflicts.iter().enumerate() { + for (conflict_index, conflict) in pending_conflicts.iter().enumerate() { lines.push(format!( " {}. Multiple agents are named '{}'. Choose one:", conflict_index + 1, @@ -822,7 +901,7 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) "BitFun/local" }; lines.push(format!( - " {}. {} ({}, {}) - /external-agents choose {} {}", + " {}. {} ({}, {}) - /builtin:agents choose {} {}", candidate_index + 1, candidate.display_name, candidate.source_label, @@ -873,7 +952,7 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) } } lines.push(format!( - " Keep unavailable: /external-agents choose {} 0", + " Keep unavailable: /builtin:agents choose {} 0", conflict_index + 1 )); lines.push( @@ -882,9 +961,73 @@ fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) } } + lines.push(String::new()); + lines.push("Current choices".to_string()); + let resolved_conflicts = &conflicts[pending_count..]; + if resolved_conflicts.is_empty() { + lines.push(" None".to_string()); + } else { + for (resolved_index, conflict) in resolved_conflicts.iter().enumerate() { + let conflict_index = pending_count + resolved_index; + lines.push(format!( + " {}. Agents named '{}':", + conflict_index + 1, + conflict.logical_id + )); + for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { + let kind = if candidate.external { + "external" + } else { + "BitFun/local" + }; + let status = if conflict.selected_candidate_id.as_deref() + == Some(candidate.candidate_id.as_str()) + { + if candidate.external + && snapshot.subagents.iter().any(|agent| { + agent.candidate_id == candidate.candidate_id + && agent.activation_state != ExternalSubagentActivationState::Active + }) + { + "selected, currently unavailable" + } else { + "selected" + } + } else { + "not selected" + }; + lines.push(format!( + " {}. {} ({}, {}) [{}] - /builtin:agents choose {} {}", + candidate_index + 1, + candidate.display_name, + candidate.source_label, + kind, + status, + conflict_index + 1, + candidate_index + 1 + )); + } + let disabled = conflict.selected_candidate_id.as_deref() + == Some(DISABLED_EXTERNAL_AGENT_CONFLICT_CHOICE); + lines.push(format!( + " Keep unavailable{}: /builtin:agents choose {} 0", + if disabled { + " [selected]" + } else { + " [not selected]" + }, + conflict_index + 1 + )); + lines.push( + " This choice is remembered until one of these agents changes. Choose another entry above to change it." + .to_string(), + ); + } + } + lines.push(String::new()); lines.push( - "Run /external-agents refresh after editing, upgrading, or removing external agent configuration." + "Run /builtin:agents refresh after editing, upgrading, or removing agent configuration in an external AI application." .to_string(), ); lines.join("\n") @@ -898,7 +1041,7 @@ fn external_agent_diagnostic_lines( let (reason, next_step) = if code.contains("configuration_unavailable") { ( "BitFun could not read its model settings.", - "Open BitFun model settings and confirm they load. If not, restart BitFun. If the problem continues, check that BitFun can read and save its settings; then refresh.", + "Open BitFun model settings, check that BitFun can read and save its settings, then refresh.", ) } else if code.contains("model_unavailable") { ( @@ -1081,7 +1224,7 @@ fn parse_external_agent_review_action( }; if command.eq_ignore_ascii_case("refresh") { if parts.next().is_some() { - return Err("usage: /external-agents refresh".to_string()); + return Err("usage: /builtin:agents refresh".to_string()); } return Ok(ExternalAgentReviewAction::Refresh); } @@ -1089,15 +1232,15 @@ fn parse_external_agent_review_action( return Ok(ExternalAgentReviewAction::Show); } let snapshot = reviewed_snapshot.or(current_snapshot).ok_or_else(|| { - "BitFun has not finished checking external agents; run /external-agents refresh".to_string() + "BitFun has not finished checking agents from external AI applications; run /builtin:agents refresh".to_string() })?; if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { let index = parse_positive_index(parts.next(), "agent number")?; if parts.next().is_some() { - return Err(format!("usage: /external-agents {command} ")); + return Err(format!("usage: /builtin:agents {command} ")); } let agent = snapshot.subagents.get(index).ok_or_else(|| { - "that agent is no longer available; reopen /external-agents".to_string() + "that agent is no longer available; run /builtin:agents refresh".to_string() })?; let approved = command.eq_ignore_ascii_case("enable"); let allowed = if approved { @@ -1114,7 +1257,7 @@ fn parse_external_agent_review_action( }; if !allowed { return Err(format!( - "agent {} is {}; reopen /external-agents for its next step", + "agent {} is {}; run /builtin:agents refresh for its next step", index + 1, external_agent_activation_label(&agent.activation_state) )); @@ -1137,16 +1280,22 @@ fn parse_external_agent_review_action( .map_err(|_| "choice number must be zero or a positive number".to_string())?; if parts.next().is_some() { return Err( - "usage: /external-agents choose ".to_string(), + "usage: /builtin:agents choose ".to_string(), ); } let conflict = snapshot .subagent_conflicts .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) + .chain( + snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_some()), + ) .nth(conflict_index) .ok_or_else(|| { - "that conflict is no longer available; reopen /external-agents".to_string() + "that conflict is no longer available; run /builtin:agents refresh".to_string() })?; let (candidate_id, approve_external) = if candidate_number == 0 { (DISABLED_EXTERNAL_AGENT_CONFLICT_CHOICE.to_string(), false) @@ -1155,7 +1304,7 @@ fn parse_external_agent_review_action( .candidates .get(candidate_number - 1) .ok_or_else(|| { - "that choice is no longer available; reopen /external-agents".to_string() + "that choice is no longer available; run /builtin:agents refresh".to_string() })?; (candidate.candidate_id.clone(), candidate.external) }; @@ -1167,7 +1316,7 @@ fn parse_external_agent_review_action( expected_preference_revision: snapshot.preference_revision, }); } - Err("usage: /external-agents [refresh | enable | disable | choose ]".to_string()) + Err("usage: /builtin:agents [refresh | enable | disable | choose ]".to_string()) } fn external_agent_mutation_result_label( @@ -1192,7 +1341,7 @@ fn external_agent_mutation_result_label( (false, Some(ExternalSubagentActivationState::Declined)) => { "External agent disabled".to_string() } - _ => "External agent decision saved; reopen /external-agents to review its current state" + _ => "External agent decision saved; run /builtin:agents refresh to review its current state" .to_string(), } } @@ -1212,7 +1361,7 @@ fn external_agent_mutation_result_label( "Agent source selected".to_string() } } else { - "Agent choices changed; reopen /external-agents before choosing".to_string() + "Agent choices changed; run /builtin:agents refresh before choosing".to_string() } } ExternalAgentReviewAction::Show => "External agents".to_string(), diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index 128a3bf83..1731fc92f 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -20,7 +20,7 @@ fn parse_external_tool_review_action( }; if command.eq_ignore_ascii_case("refresh") { if parts.next().is_some() { - return Err("usage: /external-tools refresh".to_string()); + return Err("usage: /builtin:tools refresh".to_string()); } return Ok(ExternalToolReviewAction::Refresh); } @@ -32,16 +32,16 @@ fn parse_external_tool_review_action( // so a changed target fails closed instead of reusing the same number for // a different tool after a watcher refresh. let snapshot = reviewed_snapshot.or(current_snapshot).ok_or_else(|| { - "BitFun has not finished checking external tools; run /external-tools refresh".to_string() + "BitFun has not finished checking external tools; run /builtin:tools refresh".to_string() })?; if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { let index = parse_positive_index(parts.next(), "tool number")?; if parts.next().is_some() { - return Err(format!("usage: /external-tools {command} ")); + return Err(format!("usage: /builtin:tools {command} ")); } let targets = external_tool_target_summaries(snapshot); let target = targets.get(index).ok_or_else(|| { - "that tool is no longer available; reopen /external-tools".to_string() + "that tool is no longer available; run /builtin:tools refresh".to_string() })?; let approved = command.eq_ignore_ascii_case("enable"); let allowed = if approved { @@ -51,7 +51,7 @@ fn parse_external_tool_review_action( }; if !allowed { return Err(format!( - "tool {} is {}; reopen /external-tools for its next step", + "tool {} is {}; run /builtin:tools refresh for its next step", index + 1, external_tool_activation_label(target.activation()) )); @@ -68,26 +68,32 @@ fn parse_external_tool_review_action( let candidate_index = parse_positive_index(parts.next(), "choice number")?; if parts.next().is_some() { return Err( - "usage: /external-tools choose ".to_string(), + "usage: /builtin:tools choose ".to_string(), ); } let conflict = snapshot .tool_conflicts .iter() .filter(|conflict| conflict.selected_candidate_id.is_none()) + .chain( + snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_some()), + ) .nth(conflict_index) .ok_or_else(|| { - "that conflict is no longer available; reopen /external-tools".to_string() + "that conflict is no longer available; run /builtin:tools refresh".to_string() })?; let candidate = conflict.candidates.get(candidate_index).ok_or_else(|| { - "that choice is no longer available; reopen /external-tools".to_string() + "that choice is no longer available; run /builtin:tools refresh".to_string() })?; return Ok(ExternalToolReviewAction::Choose { conflict_key: conflict.conflict_key.clone(), candidate_id: candidate.candidate_id.clone(), }); } - Err("usage: /external-tools [refresh | enable | disable | choose ]".to_string()) + Err("usage: /builtin:tools [refresh | enable | disable | choose ]".to_string()) } fn external_tool_mutation_result_label( @@ -110,7 +116,7 @@ fn external_tool_mutation_result_label( .map(|tool| &tool.activation) .collect::>(); if activations.is_empty() { - "External tool confirmation saved; reopen /external-tools to review the changed tool" + "External tool confirmation saved; run /builtin:tools refresh to review the changed tool" .to_string() } else if activations .iter() @@ -135,7 +141,7 @@ fn external_tool_mutation_result_label( { "External tool enabled".to_string() } else { - "External tool confirmation saved; reopen /external-tools to review its current state" + "External tool confirmation saved; run /builtin:tools refresh to review its current state" .to_string() } } @@ -152,7 +158,7 @@ fn external_tool_mutation_result_label( if disabled { "External tool disabled".to_string() } else { - "External tool choice saved; reopen /external-tools to review the changed tool" + "External tool choice saved; run /builtin:tools refresh to review the changed tool" .to_string() } } @@ -167,7 +173,8 @@ fn external_tool_mutation_result_label( if selected { "External tool source selected".to_string() } else { - "External tool choices changed; reopen /external-tools before choosing".to_string() + "External tool choices changed; run /builtin:tools refresh before choosing" + .to_string() } } ExternalToolReviewAction::Show => "External tools".to_string(), @@ -223,7 +230,10 @@ fn builtin_command_reconfirmation( let conflict_key = native_command_conflict_key( "local-user", command_name, - [(candidate_id.as_str(), env!("CARGO_PKG_VERSION"))], + [( + candidate_id.as_str(), + action_conflict_behavior_version(action_id), + )], ); let confirmed = preferences.choices.get(&conflict_key) == Some(&candidate_id); Some(BuiltinCommandReconfirmation { @@ -372,7 +382,7 @@ impl ChatMode { None } else { Some(format!( - "External tools need attention: {approvals} approvals, {conflicts} name conflicts, {diagnostics} diagnostics - run /external-tools" + "Tools from external AI applications need attention: {approvals} approvals, {conflicts} name conflicts, {diagnostics} diagnostics - run /builtin:tools refresh" )) } } @@ -473,7 +483,7 @@ impl ChatMode { Some(Err(MpscTryRecvError::Disconnected)) => { self.external_tool_mutation_rx = None; chat_view.set_status(Some( - "External tool update stopped before returning a result; reopen /external-tools and retry." + "External tool update stopped before returning a result; run /builtin:tools refresh and retry." .to_string(), )); return true; @@ -504,14 +514,14 @@ impl ChatMode { chat_view.set_status(Some(result_label)); } else { chat_view.set_status(Some(format!( - "{result_label}; {approvals} approvals and {conflicts} conflicts remain - run /external-tools" + "{result_label}; {approvals} approvals and {conflicts} conflicts remain - run /builtin:tools refresh" ))); } } Err(error) => { tracing::warn!("External tool review action failed: {}", error); chat_view.set_status(Some(format!( - "External tool choice was not applied: {error}. Reopen /external-tools to review current results." + "External tool choice was not applied: {error}. Run /builtin:tools refresh to review current results." ))); } } @@ -553,7 +563,7 @@ impl ChatMode { details.push(format!("{} issues", attention.diagnostics)); } Some(format!( - "External agents need attention: {} - run /external-agents", + "Agents from external AI applications need attention: {} - run /builtin:agents refresh", details.join(", ") )) } @@ -668,7 +678,7 @@ impl ChatMode { Some(Err(MpscTryRecvError::Disconnected)) => { self.external_agent_mutation_rx = None; chat_view.set_status(Some( - "External agent update stopped before returning a result; reopen /external-agents and retry." + "External agent update stopped before returning a result; run /builtin:agents refresh." .to_string(), )); return true; @@ -704,7 +714,7 @@ impl ChatMode { chat_view.set_status(Some(result_label)); } else { chat_view.set_status(Some(format!( - "{result_label}; {confirmations} confirmations and {conflicts} conflicts remain - run /external-agents" + "{result_label}; {confirmations} confirmations and {conflicts} conflicts remain - run /builtin:agents refresh" ))); } } @@ -714,7 +724,7 @@ impl ChatMode { .split_once(':') .map_or(error.as_str(), |(_, reason)| reason.trim()); chat_view.set_status(Some(format!( - "External agent choice was not applied: {reason}. Reopen /external-agents to review current results." + "External agent choice was not applied: {reason}. Run /builtin:agents refresh to review current results." ))); } } diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs index e6fb3fbda..dc7898528 100644 --- a/src/apps/cli/src/modes/chat/input.rs +++ b/src/apps/cli/src/modes/chat/input.rs @@ -212,9 +212,8 @@ impl ChatMode { KeyCode::Up => chat_view.agent_selector_up(), KeyCode::Down => chat_view.agent_selector_down(), KeyCode::Enter => { - if let Some(selected) = chat_view.agent_selector_confirm() { - chat_view.hide_agent_selector(); - self.apply_agent_selection(&selected, chat_state); + if let Some(action) = chat_view.agent_selector_confirm() { + self.handle_agent_selector_action(action, chat_view, chat_state, rt_handle); } } // Note: Esc is handled globally for navigation back @@ -493,6 +492,14 @@ impl ChatMode { .handle_provider_selection(selection, context.chat_view); } } else if context.chat_view.handle_mouse_event(&mouse) { + if let Some(action) = context.chat_view.take_pending_agent_action() { + context.this.handle_agent_selector_action( + action, + context.chat_view, + context.chat_state, + context.rt_handle, + ); + } if let Some(action) = context.chat_view.take_pending_skill_action() { context.this.handle_skill_selector_action( action, @@ -612,5 +619,4 @@ impl ChatMode { } Ok(outcome) } - } diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs index 16ca12ac6..5bcc5b67e 100644 --- a/src/apps/cli/src/modes/chat/selection.rs +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -456,8 +456,9 @@ impl ChatMode { ) { let modes = self.get_mode_agents(rt_handle); if modes.is_empty() { - chat_state.add_system_message("No mode agents available".to_string()); - return; + chat_view.set_status(Some( + "Main agent modes are unavailable; agent management remains available.".to_string(), + )); } let agent_items: Vec = modes @@ -468,7 +469,41 @@ impl ChatMode { }) .collect(); - chat_view.show_agent_selector(agent_items, Some(self.agent_type.clone())); + chat_view.show_agent_selector( + agent_items, + Some(self.agent_type.clone()), + true, + agent_mode_switch_allowed(chat_state.is_processing), + ); + } + + fn handle_agent_selector_action( + &mut self, + action: AgentSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + AgentSelectorAction::SwitchMode(selected) => { + if !agent_mode_switch_allowed(chat_state.is_processing) { + chat_view.set_status(Some( + "Agent mode cannot be changed during the current turn. Subagent and external source management remain available." + .to_string(), + )); + return; + } + chat_view.hide_agent_selector(); + self.apply_agent_selection(&selected, chat_state); + } + AgentSelectorAction::ManageSubagents => { + self.show_subagent_selector(chat_view, chat_state, rt_handle); + } + AgentSelectorAction::ReviewExternalSources => { + chat_view.hide_agent_selector(); + self.handle_external_agent_review("", chat_view, chat_state, rt_handle); + } + } } /// Apply agent selection: switch agent type @@ -498,9 +533,19 @@ impl ChatMode { // ============ MCP management ============ } +fn agent_mode_switch_allowed(is_processing: bool) -> bool { + !is_processing +} + #[cfg(test)] mod usage_metadata_tests { - use super::{SessionUsageReport, usage_report_metadata}; + use super::{agent_mode_switch_allowed, usage_report_metadata, SessionUsageReport}; + + #[test] + fn mode_switch_is_rechecked_when_an_idle_popup_outlives_turn_start() { + assert!(agent_mode_switch_allowed(false)); + assert!(!agent_mode_switch_allowed(true)); + } #[test] fn usage_metadata_preserves_the_existing_tui_transcript_schema() { diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 4f1d2be78..ba5d11de0 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -3,18 +3,20 @@ mod tests { use tokio::sync::broadcast::error::TryRecvError; use super::{ - agent_event_stream_failure, apply_model_selection_feedback, builtin_command_reconfirmation, - command_route, external_agent_attention, external_agent_diagnostic_lines, + action_opens_extension_management, agent_event_stream_failure, + apply_model_selection_feedback, builtin_command_reconfirmation, command_route, + external_agent_attention, external_agent_diagnostic_lines, external_agent_pending_notice_key, external_agent_result_is_stale, external_agent_review_text, external_command_projections, external_tool_mutation_result_label, external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, - mark_active_turn_failed, merge_external_agent_mutation_snapshot, parse_command_token, - parse_external_agent_review_action, parse_external_tool_review_action, CommandQualifier, - CommandRoute, ExternalAgentReviewAction, ExternalSourceConflictPreferences, - ExternalToolReviewAction, ModelSelectionApplyOutcome, + mark_active_turn_failed, merge_external_agent_mutation_snapshot, + native_command_conflict_key, parse_command_token, parse_external_agent_review_action, + parse_external_tool_review_action, CommandQualifier, CommandRoute, + ExternalAgentReviewAction, ExternalSourceConflictPreferences, ExternalToolReviewAction, + ModelSelectionApplyOutcome, }; - use crate::actions::{ActionState, ResolvedKeymap}; + use crate::actions::{action_conflict_behavior_version, ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; @@ -205,12 +207,15 @@ mod tests { fn external_tool_review_summary_discloses_execution_boundary_and_commands() { let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); + assert!(summary.contains("BitFun and MCP")); + assert!(summary.contains("External AI applications")); + assert!(summary.contains("Use /mcps to manage MCP servers")); assert!(summary.contains("BitFun does not run external code while checking sources")); assert!(summary.contains("filesystem, network, process, environment variables")); assert!(summary.contains("inherited environment variables")); assert!(summary.contains("processes it starts may keep running after cancellation")); - assert!(summary.contains("/external-tools enable 1")); - assert!(summary.contains("/external-tools choose 1 2")); + assert!(summary.contains("/builtin:tools enable 1")); + assert!(summary.contains("/builtin:tools choose 1 2")); assert!(summary.contains("/.opencode/tools/review.js")); assert!(summary.contains("Source folder: /.opencode/tools")); assert!(summary.contains("Applies to: current workspace")); @@ -229,16 +234,47 @@ mod tests { } #[test] - fn external_tool_runtime_recovery_explains_that_bitfun_must_restart() { + fn external_tool_runtime_recovery_starts_with_refresh_without_restart_pressure() { let mut snapshot = external_tool_review_snapshot(); snapshot.tools[0].activation = ExternalToolActivationState::RuntimeUnavailable { reason: "BitFun could not find Node.js for external tools".to_string(), }; let summary = external_tool_review_text(Some(&snapshot)); - assert!(summary.contains("install or repair Node.js, then restart BitFun")); - assert!(!summary.contains("environment, then refresh")); - assert!(!summary.contains("when BitFun started")); + assert!(summary.contains("Install or repair Node.js, then refresh")); + assert!(summary.contains("continue without external JavaScript tools")); + assert!(!summary.to_ascii_lowercase().contains("restart")); + } + + #[test] + fn external_tool_review_keeps_remembered_conflicts_visible_and_changeable() { + let mut snapshot = external_tool_review_snapshot(); + let external_candidate_id = snapshot.tools[0].definition.candidate_id(); + snapshot.tool_conflicts[0].candidates[1].candidate_id = external_candidate_id.clone(); + snapshot.tool_conflicts[0].selected_candidate_id = Some(external_candidate_id); + + let summary = external_tool_review_text(Some(&snapshot)); + assert!(summary.contains("Current choices")); + assert!(summary.contains("OpenCode review [selected, currently unavailable]")); + assert!(summary.contains("BitFun review [not selected]")); + assert!(summary.contains("/builtin:tools choose 1 1")); + + snapshot.tools[0].activation = ExternalToolActivationState::Active; + let active_summary = external_tool_review_text(Some(&snapshot)); + assert!(active_summary.contains("OpenCode review [selected]")); + assert!(!active_summary.contains("selected, currently unavailable")); + + assert_eq!( + parse_external_tool_review_action("choose 1 1", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "bitfun:review".to_string(), + } + ); + let notice = external_tool_pending_notice_key(&snapshot).unwrap(); + assert!(notice.contains("approval:decision-v1")); + assert!(notice.contains("opencode.tool.directory_read_failed")); + assert!(!notice.contains("conflict:conflict-v1")); } #[test] @@ -687,9 +723,9 @@ mod tests { assert!(summary.contains("one run only; no follow-up")); assert!(summary.contains("Model: fast")); assert!(summary.contains("Tools: read, search")); - assert!(summary.contains("/external-agents enable 1")); - assert!(summary.contains("/external-agents choose 1 2")); - assert!(summary.contains("/external-agents choose 1 0")); + assert!(summary.contains("/builtin:agents enable 1")); + assert!(summary.contains("/builtin:agents choose 1 2")); + assert!(summary.contains("/builtin:agents choose 1 0")); assert!(summary.contains("Runs on: this computer in the current workspace")); assert!(summary.contains("instructions guide the selected model")); assert!(summary.contains("may call the tools listed below")); @@ -704,6 +740,83 @@ mod tests { assert!(external_agent_review_text(Some(&unavailable)).contains("Model: unavailable")); } + #[test] + fn opening_unified_management_does_not_imply_a_native_command_choice() { + let tools = crate::actions::action_for_alias("/tools", crate::actions::ActionContext::Chat) + .unwrap(); + let agents = + crate::actions::action_for_alias("/agents", crate::actions::ActionContext::Chat) + .unwrap(); + let help = + crate::actions::action_for_alias("/help", crate::actions::ActionContext::Chat).unwrap(); + + assert!(action_opens_extension_management(tools)); + assert!(action_opens_extension_management(agents)); + assert!(!action_opens_extension_management(help)); + } + + #[test] + fn agents_management_behavior_change_invalidates_an_old_native_choice() { + let candidate_id = "bitfun.cli:switch_agent"; + let old_key = + native_command_conflict_key("local-user", "agents", [(candidate_id, "switch-mode-v1")]); + let current_key = native_command_conflict_key( + "local-user", + "agents", + [( + candidate_id, + action_conflict_behavior_version("switch_agent"), + )], + ); + + assert_ne!(old_key, current_key); + } + + #[test] + fn external_agent_review_keeps_remembered_conflicts_visible_and_changeable() { + let mut snapshot = external_agent_review_snapshot(); + snapshot.subagent_conflicts[0].selected_candidate_id = + Some("external_subagent:opencode:review:v1".to_string()); + snapshot.pending_subagent_approvals.clear(); + + let summary = external_agent_review_text(Some(&snapshot)); + assert!(summary.contains("Current choices")); + assert!( + summary.contains("Review agent (OpenCode, external) [selected, currently unavailable]") + ); + assert!(summary.contains("BitFun review (BitFun, BitFun/local) [not selected]")); + assert!(summary.contains("/builtin:agents choose 1 1")); + + snapshot.subagents[0].activation_state = ExternalSubagentActivationState::Active; + let active_summary = external_agent_review_text(Some(&snapshot)); + assert!(active_summary.contains("Review agent (OpenCode, external) [selected]")); + assert!(!active_summary.contains("selected, currently unavailable")); + + assert_eq!( + parse_external_agent_review_action("choose 1 1", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "bitfun:review".to_string(), + approve_external: false, + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); + assert_eq!(external_agent_attention(None, &snapshot).conflicts, 0); + } + + #[test] + fn external_agent_model_settings_recovery_does_not_require_restart() { + let lines = external_agent_diagnostic_lines( + "external_subagent.configuration_unavailable", + true, + "", + ); + let text = lines.join("\n"); + assert!(text.contains("check that BitFun can read and save its settings, then refresh")); + assert!(!text.to_ascii_lowercase().contains("restart")); + } + #[test] fn external_agent_review_shows_agent_storage_issues_only_on_the_agent_surface() { let mut snapshot = external_agent_review_snapshot(); diff --git a/src/apps/cli/src/ui/agent_selector.rs b/src/apps/cli/src/ui/agent_selector.rs index 2696c8ff7..6fb36627a 100644 --- a/src/apps/cli/src/ui/agent_selector.rs +++ b/src/apps/cli/src/ui/agent_selector.rs @@ -1,7 +1,7 @@ -/// Agent selector popup for switching agent mode +/// Unified Agent popup for switching modes and opening delegated-agent management /// -/// Overlay popup that displays all available agent modes -/// and allows the user to select one to switch to. +/// Main Agent rows remain directly selectable. Management rows reuse the existing +/// Subagent and external-source flows instead of adding parallel slash commands. use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::{ layout::Rect, @@ -20,6 +20,13 @@ pub(crate) struct AgentItem { pub description: String, } +#[derive(Debug, Clone)] +pub(crate) enum AgentSelectorAction { + SwitchMode(AgentItem), + ManageSubagents, + ReviewExternalSources, +} + /// Agent selector popup state pub(super) struct AgentSelectorState { items: Vec, @@ -27,6 +34,8 @@ pub(super) struct AgentSelectorState { visible: bool, /// Currently active agent ID (for highlighting) current_agent_id: Option, + include_external_sources: bool, + allow_mode_switch: bool, last_area: Option, } @@ -37,16 +46,20 @@ impl AgentSelectorState { list_state: ListState::default(), visible: false, current_agent_id: None, + include_external_sources: false, + allow_mode_switch: true, last_area: None, } } /// Show the agent selector with given agent list - pub(super) fn show(&mut self, agents: Vec, current_agent_id: Option) { - if agents.is_empty() { - return; - } - + pub(super) fn show( + &mut self, + agents: Vec, + current_agent_id: Option, + include_external_sources: bool, + allow_mode_switch: bool, + ) { let initial_idx = current_agent_id .as_ref() .and_then(|id| agents.iter().position(|a| a.id == *id)) @@ -54,6 +67,8 @@ impl AgentSelectorState { self.items = agents; self.current_agent_id = current_agent_id; + self.include_external_sources = include_external_sources; + self.allow_mode_switch = allow_mode_switch; self.list_state.select(Some(initial_idx)); self.visible = true; } @@ -66,7 +81,7 @@ impl AgentSelectorState { /// Reshow the agent selector (for back navigation) pub(super) fn reshow(&mut self) { - if !self.items.is_empty() { + if self.len() > 0 { self.visible = true; } } @@ -75,43 +90,56 @@ impl AgentSelectorState { self.visible } + pub(super) fn set_mode_switch_allowed(&mut self, allowed: bool) { + self.allow_mode_switch = allowed; + } + pub(super) fn move_up(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let len = self.items.len(); + let len = self.len(); let next = (selected + len - 1) % len; self.list_state.select(Some(next)); } pub(super) fn move_down(&mut self) { - if !self.visible || self.items.is_empty() { + if !self.visible || self.len() == 0 { return; } let selected = self.list_state.selected().unwrap_or(0); - let next = (selected + 1) % self.items.len(); + let next = (selected + 1) % self.len(); self.list_state.select(Some(next)); } - /// Get the selected agent item - pub(super) fn confirm_selection(&self) -> Option { + /// Get the selected action from the unified Agent entry. + pub(super) fn confirm_selection(&self) -> Option { if !self.visible { return None; } let idx = self.list_state.selected()?; - self.items.get(idx).cloned() + if let Some(agent) = self.items.get(idx) { + return Some(AgentSelectorAction::SwitchMode(agent.clone())); + } + if idx == self.items.len() { + return Some(AgentSelectorAction::ManageSubagents); + } + if self.include_external_sources && idx == self.items.len() + 1 { + return Some(AgentSelectorAction::ReviewExternalSources); + } + None } /// Render the agent selector popup as an overlay pub(super) fn render(&mut self, frame: &mut Frame, area: Rect, theme: &Theme) { - if !self.visible || self.items.is_empty() { + if !self.visible { self.last_area = None; return; } let popup_width = area.width.saturating_sub(4).min(60); - let popup_height = (self.items.len() as u16 + 4).min(area.height.saturating_sub(2)); + let popup_height = (self.len() as u16 + 4).min(area.height.saturating_sub(2)); if popup_height < 5 || popup_width < 20 { self.last_area = None; return; @@ -128,7 +156,7 @@ impl AgentSelectorState { }; self.last_area = Some(popup_area); - let list_items: Vec = self + let mut list_items: Vec = self .items .iter() .map(|agent| { @@ -144,24 +172,57 @@ impl AgentSelectorState { theme.style(StyleKind::Muted) }; - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let name_style = if self.allow_mode_switch { + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + } else { + theme.style(StyleKind::Muted) + }; let desc_style = theme.style(StyleKind::Muted); - - let line = Line::from(vec![ - Span::styled(marker, marker_style), + let mut spans = vec![Span::styled(marker, marker_style)]; + if !self.allow_mode_switch { + spans.push(Span::styled("After current turn · ", desc_style)); + } + spans.extend([ Span::styled(&agent.id, name_style), Span::raw(" "), - Span::styled(&agent.description, desc_style), + Span::styled(format!("Main agent · {}", agent.description), desc_style), ]); + let line = Line::from(spans); ListItem::new(line) }) .collect(); + list_items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), + Span::styled( + "Subagents", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "List, launch, or configure delegated agents", + theme.style(StyleKind::Muted), + ), + ]))); + if self.include_external_sources { + list_items.push(ListItem::new(Line::from(vec![ + Span::raw(" "), + Span::styled( + "External AI applications", + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + "Review imported agents and choices", + theme.style(StyleKind::Muted), + ), + ]))); + } let block = Block::default() .borders(Borders::ALL) .border_style(theme.style(StyleKind::Primary)) .style(Style::default().bg(theme.background)) - .title(" Select Agent (↑↓ Navigate, Enter Select, Esc Cancel) "); + .title(" Agents (↑↓ Navigate, Enter Select, Esc Cancel) "); let list = List::new(list_items) .block(block) @@ -178,7 +239,7 @@ impl AgentSelectorState { } /// Handle mouse events - pub(super) fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { + pub(super) fn handle_mouse_event(&mut self, mouse: &MouseEvent) -> Option { if !self.visible { return None; } @@ -237,10 +298,150 @@ impl AgentSelectorState { let offset = self.list_state.offset(); let index = (row - inner_y) as usize + offset; - if index >= self.items.len() { + if index >= self.len() { return None; } Some(index) } + + fn len(&self) -> usize { + self.items.len() + 1 + usize::from(self.include_external_sources) + } +} + +#[cfg(test)] +mod tests { + use super::{AgentItem, AgentSelectorAction, AgentSelectorState}; + use crate::ui::theme::Theme; + use ratatui::{backend::TestBackend, Terminal}; + + fn modes() -> Vec { + vec![ + AgentItem { + id: "agentic".to_string(), + description: "General purpose".to_string(), + }, + AgentItem { + id: "ask".to_string(), + description: "Read only".to_string(), + }, + ] + } + + #[test] + fn chat_agent_entry_keeps_modes_direct_and_adds_management_rows() { + let mut state = AgentSelectorState::new(); + state.show(modes(), Some("agentic".to_string()), true, true); + + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "agentic" + )); + state.move_down(); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ManageSubagents) + )); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ReviewExternalSources) + )); + } + + #[test] + fn startup_agent_entry_omits_session_scoped_external_sources() { + let mut state = AgentSelectorState::new(); + state.show(modes(), Some("agentic".to_string()), false, true); + state.move_down(); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ManageSubagents) + )); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "agentic" + )); + } + + #[test] + fn processing_turn_keeps_management_available_and_defers_mode_guard_to_dispatch() { + let mut state = AgentSelectorState::new(); + state.show(modes(), Some("agentic".to_string()), true, false); + + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "agentic" + )); + state.move_down(); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ManageSubagents) + )); + } + + #[test] + fn narrow_processing_popup_keeps_mode_unavailability_reason_visible() { + let mut state = AgentSelectorState::new(); + state.show(modes(), Some("agentic".to_string()), true, false); + let mut terminal = Terminal::new(TestBackend::new(32, 9)).expect("test terminal"); + + terminal + .draw(|frame| { + let area = frame.area(); + state.render(frame, area, &Theme::dark_ansi16()); + }) + .expect("render narrow agent selector"); + + let buffer = terminal.backend().buffer(); + let rendered = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + + assert!( + rendered.contains("After current turn"), + "narrow popup should explain why mode rows are disabled: {rendered:?}" + ); + } + + #[test] + fn open_popup_refreshes_mode_availability_when_the_turn_ends() { + let mut state = AgentSelectorState::new(); + state.show(modes(), Some("agentic".to_string()), true, false); + + state.set_mode_switch_allowed(true); + + assert!(state.allow_mode_switch); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::SwitchMode(AgentItem { id, .. })) if id == "agentic" + )); + } + + #[test] + fn management_rows_remain_available_when_mode_discovery_is_empty() { + let mut state = AgentSelectorState::new(); + state.show(Vec::new(), None, true, true); + + assert!(state.is_visible()); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ManageSubagents) + )); + state.move_down(); + assert!(matches!( + state.confirm_selection(), + Some(AgentSelectorAction::ReviewExternalSources) + )); + } } diff --git a/src/apps/cli/src/ui/chat/mouse.rs b/src/apps/cli/src/ui/chat/mouse.rs index 2521f2c06..c50b2ba74 100644 --- a/src/apps/cli/src/ui/chat/mouse.rs +++ b/src/apps/cli/src/ui/chat/mouse.rs @@ -19,6 +19,10 @@ impl ChatView { self.pending_skill_action.take() } + pub(crate) fn take_pending_agent_action(&mut self) -> Option { + self.pending_agent_action.take() + } + pub(crate) fn take_pending_subagent_action(&mut self) -> Option { self.pending_subagent_action.take() } @@ -35,7 +39,9 @@ impl ChatView { return true; } if self.agent_selector.captures_mouse(mouse) { - self.agent_selector.handle_mouse_event(mouse); + if let Some(action) = self.agent_selector.handle_mouse_event(mouse) { + self.pending_agent_action = Some(action); + } return true; } if self.session_selector.captures_mouse(mouse) { diff --git a/src/apps/cli/src/ui/chat/popups.rs b/src/apps/cli/src/ui/chat/popups.rs index 8e20b4d40..46a4eef46 100644 --- a/src/apps/cli/src/ui/chat/popups.rs +++ b/src/apps/cli/src/ui/chat/popups.rs @@ -157,8 +157,15 @@ impl ChatView { &mut self, agents: Vec, current_agent_id: Option, + include_external_sources: bool, + allow_mode_switch: bool, ) { - self.agent_selector.show(agents, current_agent_id); + self.agent_selector.show( + agents, + current_agent_id, + include_external_sources, + allow_mode_switch, + ); self.popup_stack.push(PopupType::AgentSelector); } @@ -182,7 +189,7 @@ impl ChatView { self.agent_selector.move_down(); } - pub(crate) fn agent_selector_confirm(&self) -> Option { + pub(crate) fn agent_selector_confirm(&self) -> Option { self.agent_selector.confirm_selection() } @@ -230,6 +237,7 @@ impl ChatView { // ============ Subagent selector methods ============ pub(crate) fn show_subagent_menu(&mut self) { + self.agent_selector.hide(); self.subagent_selector.show_menu(); self.popup_stack.push(PopupType::SubagentSelector); } @@ -533,3 +541,29 @@ impl ChatView { .update_account_progress(devices, sync_progress); } } + +#[cfg(test)] +mod tests { + use super::ChatView; + use crate::ui::agent_selector::AgentItem; + use crate::ui::theme::Theme; + + #[test] + fn opening_subagent_management_hides_the_parent_agent_selector() { + let mut view = ChatView::new(Theme::dark(), Vec::new()); + view.show_agent_selector( + vec![AgentItem { + id: "agentic".to_string(), + description: "General purpose".to_string(), + }], + Some("agentic".to_string()), + true, + true, + ); + + view.show_subagent_menu(); + + assert!(!view.agent_selector_visible()); + assert!(view.subagent_selector_visible()); + } +} diff --git a/src/apps/cli/src/ui/chat/state.rs b/src/apps/cli/src/ui/chat/state.rs index d572952a4..0dc3346c1 100644 --- a/src/apps/cli/src/ui/chat/state.rs +++ b/src/apps/cli/src/ui/chat/state.rs @@ -8,7 +8,7 @@ use ratatui::{ use std::collections::{HashMap, HashSet, VecDeque}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::agent_selector::{AgentItem, AgentSelectorAction, AgentSelectorState}; use super::command_menu::CommandMenuState; use super::command_palette::{CommandPaletteState, PaletteAction}; use super::login_form::{LoginFormAction, LoginFormState}; @@ -188,6 +188,8 @@ pub(crate) struct ChatView { pending_mcp_toggle: Option, /// Pending skill selector action from mouse click (consumed by caller) pending_skill_action: Option, + /// Pending Agent entry action from mouse click (consumed by caller) + pending_agent_action: Option, /// Pending subagent selector action from mouse click (consumed by caller) pending_subagent_action: Option, @@ -269,6 +271,7 @@ impl ChatView { pending_command: None, pending_mcp_toggle: None, pending_skill_action: None, + pending_agent_action: None, pending_subagent_action: None, pending_theme_preview: None, theme_preview_original: None, @@ -300,6 +303,8 @@ impl ChatView { pub(crate) fn set_action_state(&mut self, state: ActionState, keymap: &ResolvedKeymap) { self.shortcut_hints = keymap.compact_hints(state); + self.agent_selector + .set_mode_switch_allowed(!state.is_processing); self.command_palette.set_action_state(state); if self.command_menu.set_action_state(state) { self.command_menu diff --git a/src/apps/cli/src/ui/command_menu.rs b/src/apps/cli/src/ui/command_menu.rs index 845e62412..8faf00901 100644 --- a/src/apps/cli/src/ui/command_menu.rs +++ b/src/apps/cli/src/ui/command_menu.rs @@ -463,11 +463,11 @@ mod tests { } #[test] - fn processing_chat_hides_idle_only_actions() { + fn processing_chat_keeps_agent_management_and_hides_idle_only_actions() { let mut menu = CommandMenuState::new(ActionState::chat(true, false)); menu.update("/", 1); - assert!(!names(&menu).contains(&"/agents")); + assert!(names(&menu).contains(&"/agents")); assert!(!names(&menu).contains(&"/new")); assert!(names(&menu).contains(&"/help")); } diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index bce8a8f84..38d73c1cd 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -44,14 +44,12 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "sessions", "usage", "skills", - "subagents", "select_model", "add_model", "theme", "switch_agent", + "tools", "mcp_servers", - "external_tools", - "external_agents", "login", "logout", "help", @@ -756,13 +754,13 @@ mod tests { } #[test] - fn processing_palette_omits_idle_only_actions() { + fn processing_palette_keeps_agent_management_and_omits_idle_only_actions() { let ids = default_palette_items(ActionState::chat(true, false)) .into_iter() .map(|item| item.id) .collect::>(); - assert!(!ids.iter().any(|id| id == "switch_agent")); + assert!(ids.iter().any(|id| id == "switch_agent")); assert!(!ids.iter().any(|id| id == "new_session")); assert!(ids.iter().any(|id| id == "help")); } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 1bed3c101..192da0860 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,4 +1,4 @@ -use super::agent_selector::{AgentItem, AgentSelectorState}; +use super::agent_selector::{AgentItem, AgentSelectorAction, AgentSelectorState}; use super::command_menu::CommandMenuState; use super::command_palette::{CommandPaletteState, PaletteAction}; use super::login_form::{LoginFormAction, LoginFormState}; @@ -15,8 +15,8 @@ use super::theme::{ }; use super::theme_selector::{ThemeItem, ThemeSelectorState}; use crate::actions::{ - action_by_id, action_for_alias, ActionContext, ActionHandler, ActionSpec, ActionState, - ResolvedKeymap, + action_by_id, action_for_alias, removed_management_command_hint, ActionContext, ActionHandler, + ActionSpec, ActionState, ResolvedKeymap, }; use crate::config::CliConfig; /// Startup page module @@ -781,9 +781,8 @@ impl StartupPage { KeyCode::Up => self.agent_selector.move_up(), KeyCode::Down => self.agent_selector.move_down(), KeyCode::Enter => { - if let Some(selected) = self.agent_selector.confirm_selection() { - self.agent_selector.hide(); - self.apply_agent_selection(&selected); + if let Some(action) = self.agent_selector.confirm_selection() { + self.handle_agent_selector_action(action); } } KeyCode::Esc => self.navigate_back(), @@ -998,7 +997,6 @@ impl StartupPage { ActionHandler::SwitchAgent => self.cycle_agent(1), ActionHandler::SwitchAgentReverse => self.cycle_agent(-1), ActionHandler::Skills => self.show_skill_selector(), - ActionHandler::Subagents => self.show_subagent_selector(), ActionHandler::McpServers => { return Some(StartupResult::NewSession { prompt: Some("/mcps".to_string()), @@ -1043,8 +1041,7 @@ impl StartupPage { ActionHandler::NavigateBack => self.navigate_back(), ActionHandler::ClearConversation | ActionHandler::ReloadSkills - | ActionHandler::ExternalTools - | ActionHandler::ExternalAgents + | ActionHandler::Tools | ActionHandler::History | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool @@ -1112,9 +1109,13 @@ impl StartupPage { self.text_input.clear(); self.refresh_command_menu(); let Some(action) = action_for_alias(cmd, ActionContext::Startup) else { - self.status = Some(format!( - "Unknown command: {cmd}. Type /help for available commands." - )); + self.status = Some( + removed_management_command_hint(cmd, ActionContext::Startup) + .map(str::to_string) + .unwrap_or_else(|| { + format!("Unknown command: {cmd}. Type /help for available commands.") + }), + ); return None; }; self.dispatch_action(action, ActionState::startup(false)) @@ -1697,8 +1698,9 @@ impl StartupPage { let modes = self.get_mode_agents(); if modes.is_empty() { - self.status = Some("No mode agents available".to_string()); - return; + self.status = Some( + "Main agent modes are unavailable; agent management remains available.".to_string(), + ); } let agent_items: Vec = modes @@ -1710,7 +1712,22 @@ impl StartupPage { .collect(); self.agent_selector - .show(agent_items, Some(self.agent_type.clone())); + .show(agent_items, Some(self.agent_type.clone()), false, true); + } + + fn handle_agent_selector_action(&mut self, action: AgentSelectorAction) { + match action { + AgentSelectorAction::SwitchMode(selected) => { + self.agent_selector.hide(); + self.apply_agent_selection(&selected); + } + AgentSelectorAction::ManageSubagents => self.show_subagent_selector(), + AgentSelectorAction::ReviewExternalSources => { + self.status = Some( + "External agent sources are available after starting a session.".to_string(), + ); + } + } } fn apply_agent_selection(&mut self, selected: &AgentItem) { diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs index 0d2bf4fb3..d9f00a8f9 100644 --- a/src/crates/assembly/core/src/external_subagents.rs +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -800,8 +800,6 @@ fn reconcile_conflict_group( external: true, }); } - conflict_candidates.sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id)); - match selected.as_deref() { Some(selected_id) if selected_id == DISABLED_SUBAGENT_CONFLICT_CHOICE => { state @@ -1597,6 +1595,11 @@ mod tests { preview.routes.get("reviewer"), Some(&ExternalSubagentRoute::Unavailable) ); + assert_eq!(preview.conflicts[0].candidates.len(), 2); + assert!( + !preview.conflicts[0].candidates[0].external, + "the BitFun/local candidate must be shown before external candidates" + ); let external_id = preview.conflicts[0] .candidates .iter() diff --git a/src/crates/services/services-integrations/src/script_tool.rs b/src/crates/services/services-integrations/src/script_tool.rs index 04de9f9be..99335718e 100644 --- a/src/crates/services/services-integrations/src/script_tool.rs +++ b/src/crates/services/services-integrations/src/script_tool.rs @@ -754,7 +754,7 @@ fn port_error_kind(kind: Option<&str>) -> PortErrorKind { } pub struct NodeScriptToolRuntime { - executable: Option, + executable: RwLock>, workers: RwLock>>, load_gate: Mutex<()>, } @@ -768,12 +768,32 @@ impl Default for NodeScriptToolRuntime { impl NodeScriptToolRuntime { pub fn discover() -> Self { Self { - executable: which::which("node").ok(), + executable: RwLock::new(which::which("node").ok()), workers: RwLock::new(HashMap::new()), load_gate: Mutex::new(()), } } + async fn remember_discovered_executable(&self, discovered: Option) -> Option { + if let Some(executable) = self.executable.read().await.clone() { + return Some(executable); + } + let discovered = discovered?; + let mut executable = self.executable.write().await; + if executable.is_none() { + *executable = Some(discovered); + } + executable.clone() + } + + async fn resolve_executable(&self) -> Option { + if let Some(executable) = self.executable.read().await.clone() { + return Some(executable); + } + self.remember_discovered_executable(which::which("node").ok()) + .await + } + async fn evict_worker(&self, target_id: &str, worker: &Arc) { let mut workers = self.workers.write().await; if workers @@ -790,14 +810,13 @@ impl NodeScriptToolRuntime { #[async_trait] impl ScriptToolRuntime for NodeScriptToolRuntime { async fn availability(&self) -> ScriptToolRuntimeAvailability { - match &self.executable { + match self.resolve_executable().await { Some(executable) => ScriptToolRuntimeAvailability::Available { executable: executable.to_string_lossy().into_owned(), version: "not checked".to_string(), }, None => ScriptToolRuntimeAvailability::Unavailable { - reason: "BitFun could not find Node.js for external tools; install or repair Node.js, then restart BitFun" - .to_string(), + reason: "BitFun could not find Node.js for external tools".to_string(), }, } } @@ -840,7 +859,7 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { async fn load(&self, request: ScriptToolLoadRequest) -> PortResult { let _guard = self.load_gate.lock().await; - let executable = self.executable.as_ref().ok_or_else(|| { + let executable = self.resolve_executable().await.ok_or_else(|| { PortError::new(PortErrorKind::NotAvailable, "Node.js is not available") })?; if request.target_id.is_empty() @@ -855,7 +874,7 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { if let Some(previous) = self.workers.write().await.remove(&request.target_id) { previous.dispose(&request.target_id).await?; } - let worker = NodeWorker::spawn(executable, &request.working_directory).await?; + let worker = NodeWorker::spawn(&executable, &request.working_directory).await?; let payload = serde_json::to_value(&request).map_err(|error| { PortError::new( PortErrorKind::InvalidRequest, @@ -975,3 +994,28 @@ impl ScriptToolRuntime for NodeScriptToolRuntime { worker.dispose(target_id).await } } + +#[cfg(test)] +mod tests { + use super::NodeScriptToolRuntime; + use std::path::PathBuf; + use tokio::sync::{Mutex, RwLock}; + + #[tokio::test] + async fn missing_node_path_can_recover_without_replacing_the_runtime() { + let runtime = NodeScriptToolRuntime { + executable: RwLock::new(None), + workers: RwLock::new(Default::default()), + load_gate: Mutex::new(()), + }; + let discovered = PathBuf::from("C:/Program Files/nodejs/node.exe"); + + assert_eq!( + runtime + .remember_discovered_executable(Some(discovered.clone())) + .await, + Some(discovered.clone()) + ); + assert_eq!(*runtime.executable.read().await, Some(discovered)); + } +} diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss index 6c9df75d3..29567120f 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.scss @@ -110,6 +110,11 @@ font-size: 11px; } + &__candidate-state { + color: var(--color-text-secondary); + font-size: 11px; + } + &__ecosystem { margin-left: 4px; opacity: 0.7; diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx index 5de6023c8..813fe114a 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.test.tsx @@ -150,7 +150,10 @@ describe('ExternalSourcesConfig', () => { 'conflict-v1', 'candidate-opencode', ); - expect(container.textContent).not.toContain('conflicts.commandName'); + expect(container.textContent).toContain('conflicts.commandName'); + expect(container.textContent).toContain('conflicts.currentSelection'); + expect(container.textContent).toContain('common.selected'); + expect(container.textContent).toContain('common.notSelected'); const sourceToggle = container.querySelector('input[type="checkbox"]') as HTMLInputElement; expect(sourceToggle.checked).toBe(true); @@ -162,6 +165,221 @@ describe('ExternalSourcesConfig', () => { ); }); + it('keeps remembered command, tool, and agent choices visible and changeable', async () => { + const resolvedSnapshot = { + ...snapshot, + commandConflicts: [{ + ...snapshot.commandConflicts[0], + selectedCandidateId: 'candidate-opencode', + }], + toolConflicts: [{ + conflictKey: 'tool-conflict-v1', + toolName: 'review', + selectedCandidateId: 'builtin-review', + candidates: [{ + candidateId: 'builtin-review', + displayName: 'BitFun Review', + kind: 'built_in', + providerId: 'bitfun.builtin', + contentVersion: 'builtin-v1', + }, { + candidateId: 'external-review', + displayName: 'OpenCode Review Tool', + kind: 'external', + providerId: 'opencode.tools', + contentVersion: 'external-v1', + sourceLocation: '/.opencode/tools/review.js', + }], + }], + subagentGeneration: 4, + preferenceRevision: 7, + subagents: [], + subagentConflicts: [{ + conflictKey: 'agent-conflict-v1', + logicalId: 'review', + selectedCandidateId: '__bitfun_disabled__', + candidates: [{ + candidateId: 'builtin-agent-review', + displayName: 'BitFun Review Agent', + sourceLabel: 'BitFun', + external: false, + }, { + candidateId: 'external-agent-review', + displayName: 'OpenCode Review Agent', + sourceLabel: 'OpenCode', + external: true, + }], + }], + pendingSubagentApprovals: [], + }; + getSnapshotMock.mockResolvedValue(resolvedSnapshot); + setToolConflictChoiceMock.mockResolvedValue(resolvedSnapshot); + chooseSubagentConflictMock.mockResolvedValue(resolvedSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('conflicts.currentSelection'); + expect(container.textContent).toContain('toolConflicts.currentSelection'); + expect(container.textContent).toContain('agentConflicts.keptUnavailable'); + expect(container.textContent).toContain('BitFun Review'); + expect(container.textContent).toContain('OpenCode Review Tool'); + expect(container.textContent).toContain('BitFun Review Agent'); + expect(container.textContent).toContain('OpenCode Review Agent'); + + const externalTool = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('OpenCode Review Tool')); + await act(async () => externalTool?.click()); + expect(setToolConflictChoiceMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'tool-conflict-v1', + 'external-review', + ); + + const bitfunAgent = Array.from(container.querySelectorAll('button')).find((button) => + button.textContent?.includes('BitFun Review Agent')); + await act(async () => bitfunAgent?.click()); + expect(chooseSubagentConflictMock).toHaveBeenCalledWith( + 'D:/workspace/project', + 'agent-conflict-v1', + 'builtin-agent-review', + false, + 4, + 7, + ); + }); + + it('does not present a selected but disabled external agent as currently used', async () => { + const selectedDisabledSnapshot = { + ...snapshot, + commandConflicts: [{ + ...snapshot.commandConflicts[0], + selectedCandidateId: 'candidate-opencode', + candidates: [{ + ...snapshot.commandConflicts[0].candidates[0], + availability: { + state: 'restricted', + reason: 'Unsupported command capability', + required_capabilities: ['shell'], + }, + }, snapshot.commandConflicts[0].candidates[1]], + }], + tools: [{ + definition: { + id: { + target: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'review', + }, + exportId: 'other', + }, + name: 'other', + descriptionPreview: 'Another export in the same module', + modulePath: '/.opencode/tools/review.js', + workingDirectory: '', + runtimeKind: 'java_script', + capabilities: [], + contentVersion: 'tool-v1', + staticStatus: { state: 'ready' }, + }, + approvalKey: 'other-tool-approval-v1', + decisionKey: 'other-tool-decision-v1', + activation: { state: 'active' }, + }, { + definition: { + id: { + target: { + source: { providerId: 'opencode.tools', sourceId: 'project' }, + localId: 'review', + }, + exportId: 'review', + }, + name: 'review', + descriptionPreview: 'Review a change', + modulePath: '/.opencode/tools/review.js', + workingDirectory: '', + runtimeKind: 'java_script', + capabilities: [], + contentVersion: 'tool-v1', + staticStatus: { state: 'ready' }, + }, + approvalKey: 'tool-approval-v1', + decisionKey: 'tool-decision-v1', + activation: { state: 'disabled' }, + }], + toolConflicts: [{ + conflictKey: 'tool-conflict-v1', + toolName: 'review', + selectedCandidateId: 'external-review', + candidates: [{ + candidateId: 'builtin-review', + displayName: 'BitFun Review', + kind: 'built_in', + providerId: 'bitfun.builtin', + contentVersion: 'builtin-v1', + }, { + candidateId: 'external-review', + displayName: 'OpenCode Review', + kind: 'external', + providerId: 'opencode.tools', + contentVersion: 'tool-v1', + source: { providerId: 'opencode.tools', sourceId: 'project' }, + sourceLocation: '/.opencode/tools/review.js', + }], + }], + subagentGeneration: 4, + preferenceRevision: 7, + subagents: [{ + candidateId: 'external-agent-review', + logicalId: 'review', + displayName: 'OpenCode Review Agent', + description: 'Review a change', + providerLabel: 'OpenCode', + scope: 'project', + sourceKeys: [{ providerId: 'opencode.agents', sourceId: 'review' }], + sourceLocationLabels: ['/.opencode/agents/review.md'], + sourceCount: 1, + effectiveModelLabel: 'fast', + effectiveToolLabels: ['Read'], + supportsFollowUp: false, + compatibilityState: 'ready', + diagnostics: [], + activationState: { state: 'disabled' }, + decisionKey: 'agent-decision-v1', + }], + subagentConflicts: [{ + conflictKey: 'agent-conflict-v1', + logicalId: 'review', + selectedCandidateId: 'external-agent-review', + candidates: [{ + candidateId: 'builtin-agent-review', + displayName: 'BitFun Review Agent', + sourceLabel: 'BitFun', + external: false, + }, { + candidateId: 'external-agent-review', + displayName: 'OpenCode Review Agent', + sourceLabel: 'OpenCode', + external: true, + }], + }], + pendingSubagentApprovals: [], + }; + getSnapshotMock.mockResolvedValue(selectedDisabledSnapshot); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + expect(container.textContent).toContain('common.selectedUnavailable'); + expect(container.textContent).toContain('conflicts.currentSelectionUnavailable'); + expect(container.textContent).toContain('toolConflicts.currentSelectionUnavailable'); + expect(container.textContent).toContain('agentConflicts.currentSelectionUnavailable'); + }); + it('keeps discovery non-blocking while an initial refresh completes', async () => { getSnapshotMock .mockResolvedValueOnce({ diff --git a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx index 3260c364f..70abf73f8 100644 --- a/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/ExternalSourcesConfig.tsx @@ -92,6 +92,15 @@ type AgentChangeNotice = { message: string; }; +const DISABLED_SUBAGENT_CONFLICT_CHOICE = '__bitfun_disabled__'; + +function unresolvedFirst(items: T[]): T[] { + return [ + ...items.filter((item) => !item.selectedCandidateId), + ...items.filter((item) => item.selectedCandidateId), + ]; +} + function activeAgentAvailabilityChanges( previous: ExternalSourceCatalogSnapshot | null, next: ExternalSourceCatalogSnapshot, @@ -339,24 +348,18 @@ const ExternalSourcesConfig: React.FC = () => { return counts; }, [snapshot]); - const pendingConflicts = useMemo( - () => (snapshot?.commandConflicts ?? []).filter( - (conflict) => !conflict.selectedCandidateId, - ), + const commandConflicts = useMemo( + () => unresolvedFirst(snapshot?.commandConflicts ?? []), [snapshot?.commandConflicts], ); - const pendingToolConflicts = useMemo( - () => (snapshot?.toolConflicts ?? []).filter( - (conflict) => !conflict.selectedCandidateId, - ), + const toolConflicts = useMemo( + () => unresolvedFirst(snapshot?.toolConflicts ?? []), [snapshot?.toolConflicts], ); - const pendingAgentConflicts = useMemo( - () => (snapshot?.subagentConflicts ?? []).filter( - (conflict) => !conflict.selectedCandidateId, - ), + const agentConflicts = useMemo( + () => unresolvedFirst(snapshot?.subagentConflicts ?? []), [snapshot?.subagentConflicts], ); @@ -704,18 +707,27 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} - {pendingAgentConflicts.length > 0 ? ( + {agentConflicts.length > 0 ? ( - {pendingAgentConflicts.map((conflict) => ( -
+ {agentConflicts.map((conflict) => { + const selectedExternalAgent = snapshot?.subagents?.find((agent) => ( + agent.candidateId === conflict.selectedCandidateId + )); + const selectedChoiceUnavailable = Boolean( + selectedExternalAgent + && selectedExternalAgent.activationState.state !== 'active', + ); + return ( +
{t('agentConflicts.agentName', { name: conflict.logicalId })}
{conflict.candidates.map((candidate) => { + const selected = conflict.selectedCandidateId === candidate.candidateId; const externalAgent = candidate.external ? snapshot?.subagents?.find((agent) => ( agent.candidateId === candidate.candidateId @@ -724,9 +736,10 @@ const ExternalSourcesConfig: React.FC = () => { return (
+ + {t(selected + ? selectedChoiceUnavailable + ? 'common.selectedUnavailable' + : 'common.selected' + : conflict.selectedCandidateId + ? 'common.notSelected' + : 'common.availableChoice')} + {externalAgent ? (
{t('agents.model', { model: externalAgentModelLabel(externalAgent.effectiveModelLabel, t) })} @@ -771,12 +793,17 @@ const ExternalSourcesConfig: React.FC = () => { ); })}
- {t('agentConflicts.pending')} + {conflict.selectedCandidateId === DISABLED_SUBAGENT_CONFLICT_CHOICE + ? t('agentConflicts.keptUnavailable') + : conflict.selectedCandidateId + ? t(selectedChoiceUnavailable + ? 'agentConflicts.currentSelectionUnavailable' + : 'agentConflicts.currentSelection') + : t('agentConflicts.pending')}
-
- ))} +
+ ); + })} ) : null} @@ -1097,13 +1131,18 @@ const ExternalSourcesConfig: React.FC = () => { ) : null} - {pendingConflicts.length > 0 ? ( + {commandConflicts.length > 0 ? ( - {pendingConflicts.map((conflict) => ( -
+ {commandConflicts.map((conflict) => { + const selectedChoiceUnavailable = conflict.candidates.some((candidate) => ( + candidate.candidateId === conflict.selectedCandidateId + && candidate.availability.state !== 'available' + )); + return ( +
{t('conflicts.commandName', { name: conflict.commandName })}
@@ -1131,6 +1170,17 @@ const ExternalSourcesConfig: React.FC = () => { {candidate.ecosystemId} + + {t(selected + ? selectedChoiceUnavailable + ? 'common.selectedUnavailable' + : 'common.selected' + : !available + ? 'conflicts.restricted' + : conflict.selectedCandidateId + ? 'common.notSelected' + : 'common.availableChoice')} +
{candidate.commandDescription} {' · '} @@ -1147,56 +1197,94 @@ const ExternalSourcesConfig: React.FC = () => { ); })}
- {!conflict.selectedCandidateId ? ( -
- {t('conflicts.pending')} -
- ) : null} -
- ))} +
+ {conflict.selectedCandidateId + ? t(selectedChoiceUnavailable + ? 'conflicts.currentSelectionUnavailable' + : 'conflicts.currentSelection') + : t('conflicts.pending')} +
+
+ ); + })}
) : null} - {pendingToolConflicts.length > 0 ? ( + {toolConflicts.length > 0 ? ( - {pendingToolConflicts.map((conflict) => ( -
+ {toolConflicts.map((conflict) => { + const selectedCandidate = conflict.candidates.find((candidate) => ( + candidate.candidateId === conflict.selectedCandidateId + )); + const selectedExternalTool = selectedCandidate?.kind === 'external' + ? snapshot?.tools?.find((tool) => ( + tool.definition.id.target.source.providerId + === selectedCandidate.source?.providerId + && tool.definition.id.target.source.sourceId + === selectedCandidate.source?.sourceId + && tool.definition.modulePath === selectedCandidate.sourceLocation + && tool.definition.name === conflict.toolName + && tool.definition.contentVersion === selectedCandidate.contentVersion + )) + : undefined; + const selectedChoiceUnavailable = selectedCandidate?.kind === 'external' + && selectedExternalTool?.activation.state !== 'active'; + return ( +
{t('toolConflicts.toolName', { name: conflict.toolName })}
- {conflict.candidates.map((candidate) => ( -
- + + {t(selected + ? selectedChoiceUnavailable + ? 'common.selectedUnavailable' + : 'common.selected' + : conflict.selectedCandidateId + ? 'common.notSelected' + : 'common.availableChoice')} - -
- {candidate.sourceLocation - ? abbreviatedLocation(candidate.sourceLocation) - : candidate.providerId} +
+ {candidate.sourceLocation + ? abbreviatedLocation(candidate.sourceLocation) + : candidate.providerId} +
-
- ))} + ); + })}
- {t('toolConflicts.pending')} + {conflict.selectedCandidateId + ? t(selectedChoiceUnavailable + ? 'toolConflicts.currentSelectionUnavailable' + : 'toolConflicts.currentSelection') + : t('toolConflicts.pending')}
-
- ))} +
+ ); + })}
) : null} diff --git a/src/web-ui/src/locales/en-US/settings/external-sources.json b/src/web-ui/src/locales/en-US/settings/external-sources.json index bad5be3f1..f368b7bad 100644 --- a/src/web-ui/src/locales/en-US/settings/external-sources.json +++ b/src/web-ui/src/locales/en-US/settings/external-sources.json @@ -8,7 +8,11 @@ "hideDetails": "Hide details", "close": "Close", "unknown": "Unknown", - "technicalDetails": "Technical details" + "technicalDetails": "Technical details", + "selected": "Currently used", + "selectedUnavailable": "Selected, currently unavailable", + "notSelected": "Not used", + "availableChoice": "Available choice" }, "actions": { "refresh": "Refresh", @@ -96,7 +100,7 @@ "configurationUnavailable": { "reason": "BitFun could not read its model settings.", "impact": "{{impact}}", - "nextStep": "Open BitFun model settings and confirm they load. If not, restart BitFun. If the problem continues, check that BitFun can read and save its settings; then refresh." + "nextStep": "Open BitFun model settings, check that BitFun can read and save its settings, then refresh." }, "modelUnavailable": { "reason": "The requested model is not available in BitFun.", @@ -125,11 +129,14 @@ } }, "agentConflicts": { - "title": "Choose between agents with the same name", - "description": "BitFun will not choose one for you. Your choice is remembered until one of these agents changes.", + "title": "Agents with the same name", + "description": "BitFun does not choose silently. Pending and current choices stay visible, and a choice is remembered until one of these agents changes.", "agentName": "Multiple agents are named {{name}}", "disableAll": "Do not use any of these", "pending": "Choose which agent to use for future tasks. Choosing an external agent also confirms the model, tools, and where it runs as shown below.", + "currentSelection": "The current choice is used for new tasks. Choose another option above to change it.", + "currentSelectionUnavailable": "This source is still selected, but it is currently disabled or unavailable for new tasks. Enable or repair it, choose another option, or keep this name unavailable.", + "keptUnavailable": "This agent name is kept unavailable. Choose an option above when you want to use one.", "selectionApproves": "This choice confirms the model, tools, run location, and configuration sources shown below." }, "tools": { @@ -187,14 +194,16 @@ "active": "No action needed. Disable these tools to stop using this source's tools.", "conflict": "Choose which tool to use below, or leave them disabled.", "unsupported": "Change the source to a single JavaScript file supported by BitFun, then refresh.", - "runtime_unavailable": "Install or repair Node.js, then restart BitFun to check again.", + "runtime_unavailable": "Install or repair Node.js, then refresh. You can continue without these external JavaScript tools while the run environment is unavailable.", "load_failed": "Fix the source and refresh, or keep these tools disabled." }, "toolConflicts": { - "title": "Choose between tools with the same name", - "description": "Until you choose, BitFun keeps using an available local or MCP tool. If none is available, this name stays disabled. You will not be asked again unless the available choices change.", + "title": "Tools with the same name", + "description": "Pending and current choices stay visible. Until you choose, BitFun keeps using an available local or MCP tool; if none is available, this name stays disabled. Choices are remembered until the available tools change.", "toolName": "Multiple tools are named {{name}}", - "pending": "Choose which tool to use when this name is called." + "pending": "Choose which tool to use when this name is called.", + "currentSelection": "The current choice is used for this tool name. Choose another option above to change it.", + "currentSelectionUnavailable": "This tool source is still selected, but it is currently disabled or unavailable. Enable or repair it, or choose another tool." }, "toolCandidateKind": { "built_in": "Built in", @@ -226,10 +235,12 @@ "using_last_valid_version": "Using the last working version" }, "conflicts": { - "title": "Choose between commands with the same name", - "description": "When multiple sources provide the same command, BitFun will not choose one for you. Your choice is remembered until one of the sources changes.", + "title": "Commands with the same name", + "description": "BitFun does not choose silently. Pending and current choices stay visible, and a choice is remembered until one of the sources changes.", "commandName": "More than one source provides /{{name}}", "pending": "Choose an available source to use this command.", + "currentSelection": "The current source is used for this command. Choose another available source above to change it.", + "currentSelectionUnavailable": "This command source is still selected, but it cannot run in its current state. Choose an available source or fix this source first.", "restricted": "Not supported in this version" } } diff --git a/src/web-ui/src/locales/zh-CN/settings/external-sources.json b/src/web-ui/src/locales/zh-CN/settings/external-sources.json index 2c18d75bc..cb4d8623f 100644 --- a/src/web-ui/src/locales/zh-CN/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-CN/settings/external-sources.json @@ -8,7 +8,11 @@ "hideDetails": "收起详情", "close": "关闭", "unknown": "未知", - "technicalDetails": "技术详情" + "technicalDetails": "技术详情", + "selected": "当前使用", + "selectedUnavailable": "已选择,当前不可用", + "notSelected": "未使用", + "availableChoice": "可选" }, "actions": { "refresh": "刷新", @@ -96,7 +100,7 @@ "configurationUnavailable": { "reason": "BitFun 无法读取模型设置。", "impact": "{{impact}}", - "nextStep": "请打开 BitFun 的模型设置并确认可以正常加载;如无法加载,请重启 BitFun。问题仍存在时,请确认 BitFun 可以正常读取和保存设置,然后刷新。" + "nextStep": "请打开 BitFun 的模型设置,确认 BitFun 可以正常读取和保存设置,然后刷新。" }, "modelUnavailable": { "reason": "该 Agent 请求的模型在 BitFun 中不可用。", @@ -125,11 +129,14 @@ } }, "agentConflicts": { - "title": "选择同名 Agent", - "description": "BitFun 不会替你决定使用哪一个。你的选择会保留,直到其中一个 Agent 的设置发生变化。", + "title": "同名 Agent", + "description": "BitFun 不会静默替你选择。待选择项和当前选择都会保留显示;其中一个 Agent 发生变化前,BitFun 会记住你的选择。", "agentName": "找到多个名为 {{name}} 的 Agent", "disableAll": "暂不使用任何一个", "pending": "请选择今后执行任务时使用哪一个 Agent。选择外部 Agent 时,也会确认下方显示的模型、工具和运行位置。", + "currentSelection": "新任务会使用当前选择。如需更改,请在上方选择其他选项。", + "currentSelectionUnavailable": "仍保留此来源为当前选择,但它目前已停用或无法用于新任务。请启用或修复它、改选其他来源,或保持此名称不可用。", + "keptUnavailable": "当前保持此 Agent 名称不可用。需要使用时,可在上方选择一个 Agent。", "selectionApproves": "选择后,也表示你确认了下方显示的模型、工具、运行位置和配置来源。" }, "tools": { @@ -187,14 +194,16 @@ "active": "无需操作。如需停止使用该来源的全部工具,请停用这些工具。", "conflict": "请在下方选择要使用的工具,或继续保持停用。", "unsupported": "请将来源改为 BitFun 当前支持的单文件 JavaScript 格式,然后刷新。", - "runtime_unavailable": "请安装或修复 Node.js,然后重启 BitFun 重新检查。", + "runtime_unavailable": "请安装或修复 Node.js,然后刷新。运行环境不可用期间,你仍可继续使用其他功能。", "load_failed": "请修复来源后刷新,或继续停用这些工具。" }, "toolConflicts": { - "title": "选择同名工具", - "description": "选择前,BitFun 会继续使用当前可用的本地或 MCP 工具;如果没有可用工具,则暂时停用该名称。选项发生变化前不会再次询问。", + "title": "同名工具", + "description": "待选择项和当前选择都会保留显示。选择前,BitFun 会继续使用可用的本地或 MCP 工具;若没有可用工具,则停用该名称。可用工具发生变化前,BitFun 会记住你的选择。", "toolName": "找到多个名为 {{name}} 的工具", - "pending": "请选择今后调用这个工具名称时使用哪一个工具。" + "pending": "请选择今后调用这个工具名称时使用哪一个工具。", + "currentSelection": "调用此工具名称时会使用当前选择。如需更改,请在上方选择其他工具。", + "currentSelectionUnavailable": "仍保留此工具来源为当前选择,但它目前已停用或不可用。请启用或修复它,或选择其他工具。" }, "toolCandidateKind": { "built_in": "内置", @@ -226,10 +235,12 @@ "using_last_valid_version": "正在使用上次可用的内容" }, "conflicts": { - "title": "选择同名命令", - "description": "多个来源提供同名命令时,BitFun 不会替你选择。你的选择会保留,直到其中一个来源发生变化。", + "title": "同名命令", + "description": "BitFun 不会静默替你选择。待选择项和当前选择都会保留显示;其中一个来源发生变化前,BitFun 会记住你的选择。", "commandName": "多个来源提供了 /{{name}}", "pending": "请选择使用哪个来源的命令。", + "currentSelection": "当前命令会使用已选来源。如需更改,请在上方选择其他可用来源。", + "currentSelectionUnavailable": "仍保留此命令来源为当前选择,但它目前无法运行。请改选可用来源,或先修复此来源。", "restricted": "当前版本暂不支持" } } diff --git a/src/web-ui/src/locales/zh-TW/settings/external-sources.json b/src/web-ui/src/locales/zh-TW/settings/external-sources.json index 25ae9f8d6..ae4f072e2 100644 --- a/src/web-ui/src/locales/zh-TW/settings/external-sources.json +++ b/src/web-ui/src/locales/zh-TW/settings/external-sources.json @@ -8,7 +8,11 @@ "hideDetails": "收合詳細資料", "close": "關閉", "unknown": "未知", - "technicalDetails": "技術詳細資料" + "technicalDetails": "技術詳細資料", + "selected": "目前使用", + "selectedUnavailable": "已選擇,目前無法使用", + "notSelected": "未使用", + "availableChoice": "可選" }, "actions": { "refresh": "重新整理", @@ -96,7 +100,7 @@ "configurationUnavailable": { "reason": "BitFun 無法讀取模型設定。", "impact": "{{impact}}", - "nextStep": "請開啟 BitFun 的模型設定並確認可以正常載入;若無法載入,請重新啟動 BitFun。問題仍存在時,請確認 BitFun 可以正常讀取與儲存設定,然後重新整理。" + "nextStep": "請開啟 BitFun 的模型設定,確認 BitFun 可以正常讀取與儲存設定,然後重新整理。" }, "modelUnavailable": { "reason": "此 Agent 要求的模型在 BitFun 中不可用。", @@ -125,11 +129,14 @@ } }, "agentConflicts": { - "title": "選擇同名 Agent", - "description": "BitFun 不會替你決定使用哪一個。你的選擇會保留,直到其中一個 Agent 的設定發生變更。", + "title": "同名 Agent", + "description": "BitFun 不會無提示地替你選擇。待選項目與目前選擇都會保留顯示;其中一個 Agent 發生變更前,BitFun 會記住你的選擇。", "agentName": "找到多個名為 {{name}} 的 Agent", "disableAll": "暫不使用任何一個", "pending": "請選擇日後執行任務時使用哪一個 Agent。選擇外部 Agent 時,也會確認下方顯示的模型、工具與執行位置。", + "currentSelection": "新任務會使用目前選擇。若要變更,請在上方選擇其他選項。", + "currentSelectionUnavailable": "仍保留此來源為目前選擇,但它目前已停用或無法用於新任務。請啟用或修復它、改選其他來源,或讓此名稱維持無法使用。", + "keptUnavailable": "目前保持此 Agent 名稱不可用。需要使用時,可在上方選擇一個 Agent。", "selectionApproves": "選擇後,也表示你確認了下方顯示的模型、工具、執行位置與設定來源。" }, "tools": { @@ -187,14 +194,16 @@ "active": "無需操作。若要停止使用該來源的所有工具,請停用這些工具。", "conflict": "請在下方選擇要使用的工具,或繼續保持停用。", "unsupported": "請將來源改為 BitFun 目前支援的單一 JavaScript 檔案格式,然後重新整理。", - "runtime_unavailable": "請安裝或修復 Node.js,然後重新啟動 BitFun 以再次檢查。", + "runtime_unavailable": "請安裝或修復 Node.js,然後重新整理。執行環境無法使用期間,你仍可繼續使用其他功能。", "load_failed": "請修正來源後重新整理,或繼續停用這些工具。" }, "toolConflicts": { - "title": "選擇同名工具", - "description": "選擇前,BitFun 會繼續使用目前可用的本機或 MCP 工具;如果沒有可用工具,則暫時停用此名稱。選項發生變更前不會再次詢問。", + "title": "同名工具", + "description": "待選項目與目前選擇都會保留顯示。選擇前,BitFun 會繼續使用可用的本機或 MCP 工具;若沒有可用工具,則停用此名稱。可用工具發生變更前,BitFun 會記住你的選擇。", "toolName": "找到多個名為 {{name}} 的工具", - "pending": "請選擇日後呼叫此工具名稱時使用哪一個工具。" + "pending": "請選擇日後呼叫此工具名稱時使用哪一個工具。", + "currentSelection": "呼叫此工具名稱時會使用目前選擇。若要變更,請在上方選擇其他工具。", + "currentSelectionUnavailable": "仍保留此工具來源為目前選擇,但它目前已停用或無法使用。請啟用或修復它,或選擇其他工具。" }, "toolCandidateKind": { "built_in": "內建", @@ -226,10 +235,12 @@ "using_last_valid_version": "正在使用上次可用的內容" }, "conflicts": { - "title": "選擇同名命令", - "description": "多個來源提供同名命令時,BitFun 不會替你選擇。你的選擇會保留,直到其中一個來源發生變更。", + "title": "同名命令", + "description": "BitFun 不會無提示地替你選擇。待選項目與目前選擇都會保留顯示;其中一個來源發生變更前,BitFun 會記住你的選擇。", "commandName": "多個來源提供了 /{{name}}", "pending": "請選擇使用哪個來源的命令。", + "currentSelection": "目前命令會使用已選來源。若要變更,請在上方選擇其他可用來源。", + "currentSelectionUnavailable": "仍保留此命令來源為目前選擇,但它目前無法執行。請改選可用來源,或先修復此來源。", "restricted": "目前版本暫不支援" } }