diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index fac00bc34f..754a5901fd 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -454,8 +454,9 @@ Plugin/Tool、可执行 Skill/Command、MCP/LSP/Formatter、远程 Reference 等 规则文件优先复用项目已有文件,不复制出第二份内容。若不同生态规则冲突,导入报告必须展示目标文件、 优先级和冲突段,不能自动拼接。 -现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现需要补来源身份、全局/项目作用域、自动应用偏好、 -变化监听和可见性测试。OpenCode 兼容来源继续直接发现官方目录;Codex/Claude 是否增加新的持续来源另行决定。 +现有对 `.claude/.codex/.opencode/.agents` Skill 根的直接发现已经保留来源身份和全局/项目作用域,并在 GUI/TUI +展示来源和默认覆盖状态,模式配置再展示实际采用项;固定根顺序保持为 Skill Registry 的独立回归契约。变化监听与可见性测试仍需 +后续补齐。OpenCode 兼容来源继续直接发现官方目录;Codex/Claude 是否增加新的持续来源另行决定。 Skill 说明和索引可按 L1 处理,脚本、URL 和外部依赖按 L2 确认;显式导入仍不得复制凭据值。MCP 启用状态按 OpenCode 来源解释,首次连接、策略限制和凭据缺失分别显示。 diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 5e7a3dcaf4..0c2bc51e47 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -62,7 +62,8 @@ BitFun 采用“一个能力核心,多种宿主适配”的方向,而不是 |---|---|---| | `exclusive` | 主 Session Store、最终 Compactor 等只能有一个 active owner 的能力 | 组装时选出一个 Provider;运行时不允许两个实现双写。 | | `ordered-chain` | Context Transformer、Prompt/Tool Hook、验证器 | 顺序由能力 owner 或生态 adapter 明确;每步校验,失败策略类型化。 | -| `namespace-union` | Tools、Skills、Commands、Agents | 先按来源限定身份保留候选,再按名称和作用域解析;同名不静默跨生态覆盖。 | +| `namespace-union` | Tools、Commands、Agents | 先按来源限定身份保留候选,再按名称和作用域解析;同名不静默跨生态覆盖。冲突界面先列 BitFun、再按稳定 provider 身份列其他生态,但展示顺序不自动决定胜者。 | +| `ordered-namespace` | 现有 Skill 根 | 保留来源限定身份并按 Skill Registry 已发布的根顺序解析同名项;被覆盖项继续可见。来源元数据只用于解释结果,不参与重新排序。 | | `fallback` | Memory Retriever、模型 Provider、外部服务 | 只对声明为可恢复的错误切换;权限拒绝、取消和副作用不自动 fallback。 | | `fan-out` | 只读事件 Observer、运维遥测 | Observer 互相隔离;不能阻塞或改变权威业务结果。 | @@ -279,6 +280,9 @@ Host Adapter 必须把宿主 `AbortSignal`、turn interruption 或 session stop 内容版本、执行域或权限包络变化后重新求值。产品保护项只限身份、数据隔离、权限入口、故障恢复、升级/卸载完整性 和法律要求,不能把所有内置能力设成不可覆盖。 +同名候选在 GUI/TUI 中固定先展示 BitFun 来源,其余生态按稳定 `provider_id` 排序,同一生态内部沿用 adapter 的 +正式来源顺序。这个顺序只用于减少阅读成本;用户未选择时仍保持冲突未决,不能把“BitFun 排在第一”误实现为静默激活。 + ## 7. 权限、信任与执行边界 权限检查分成五个不同阶段: diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 2e370ed48c..edf0a41e9a 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -38,7 +38,9 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器、prim 1. 自动发现当前执行域中的用户全局、项目和工作区外部来源,不阻塞项目打开、TUI 输入或无关会话。 2. 当前能够安全消费且不存在同名冲突的低风险内容默认无感应用,并通过可撤销的非阻塞摘要说明来源和影响; - 外部能力与产品本地能力、或独立外部 provider 之间发生同名冲突时,不得静默选择胜者。 + Command、Tool、Subagent 等外部可执行能力与产品本地能力、或独立外部 provider 之间发生同名冲突时,不得 + 静默选择胜者。现有 Skill 根继续按已发布顺序解析,但必须展示来源和默认覆盖状态;带模式的管理界面再展示 + 应用模式开关后的实际采用项。 3. 插件、Hook、Command、MCP 等可执行或有外部副作用的内容先发现,首次启用或能力扩大时再由用户确认。 4. 运行中感知来源修改、升级、删除和重新出现;成功更新安全切换,失败时优雅保留仍合规的上一有效代次。 5. 用户始终能解释“发现了什么、来自哪里、当前是否生效、为何降级、下一步能做什么”。 @@ -232,7 +234,7 @@ flowchart LR | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | 共享生命周期协调器 | 调用已注册 provider、生成不可变候选、按 provider 原子替换、保留隔离诊断,并请求能力 owner 切换 | 按生态 ID 分支业务行为、解析生态文件、直接提交配置、工具、权限或界面状态。 | | 产品展示投影 | 按作用域、工作区或用户目录关系统一生成安全来源位置,清理可见诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | -| 冲突解析 | 对独立 provider 或产品本地能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用 | 用 adapter 优先级静默覆盖另一生态或本地能力,或把选择写回外部文件。 | +| 冲突解析 | 对独立 provider 或产品本地可执行能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用。现有 Skill 固定根顺序由 Skill owner 独立维护 | 用 adapter 优先级静默覆盖另一生态或本地可执行能力,或把选择写回外部文件。 | | 激活策略与各能力 owner | 根据风险、用户选择、组织上限和执行域决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | | Runtime Configuration Service | 应用兼容配置视图,执行显式导入、冲突预览、原子写入和撤销 | 读取凭据值或加载插件代码。 | | Plugin Runtime Host / 执行服务 | 准备代次、监督进程、期限、取消、背压、健康和贡献生命周期 | 决定来源优先级、产品提示策略或最终业务状态。 | diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 13f46a8e19..5f5029a11c 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -37,7 +37,8 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 8. **平台差异留在入口和具体能力实现**:target 只选择 ABI,feature 只控制确实可选的依赖;共享内核不按平台 分叉业务语义,也不新增包含所有 OS 方法的总接口。新端口必须有当前调用方。 9. **发现无感,生效按风险分级**:外部用户/项目来源后台发现,不阻塞产品入口;无冲突的低风险声明式内容可自动应用并提供撤销; - 与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用;可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 + Command、Tool、Subagent 等可执行来源与产品本地能力或独立外部 provider 同名时必须由用户选择,且选择只在候选身份与内容版本不变时复用。现有 Skill 根继续按已发布顺序解析,并展示来源和默认覆盖项;带模式的管理界面展示应用模式开关后的实际采用项; + 可执行来源首次启用或能力扩大时形成非阻塞确认。激活后的本地 OpenCode 扩展默认按当前用户能力 运行;经 BitFun 能力接口的调用可细分限制,脚本直接文件/网络/进程能力只在真实操作系统或容器边界存在时可 粗粒度收紧,否则停用相应 target。策略降级必须与待确认、解析错误和插件故障分开显示。 10. **开放权限不降低可靠性**:第三方代码始终位于受监督的独立执行进程,具备期限、取消、背压、崩溃回收、 @@ -284,8 +285,8 @@ flowchart LR 维护,不能因为某一项降级就把整体状态写成“完整覆盖”。 产品内置扩展与用户插件可以复用主机可靠性和最终能力归属,但来源、升级、卸载和产品必要性不同。只有产品 -身份、安全恢复或法律要求等少量明确保护项不可被覆盖;普通内置命令、工具和主题默认可以按 OpenCode 兼容顺序 -被用户扩展替换或关闭。具体规则见 +身份、安全恢复或法律要求等少量明确保护项不可被覆盖;普通内置命令、工具和主题可经用户明确选择被外部扩展 +替换或关闭,不能按注册或适配器顺序静默切换。具体规则见 [`product-customization-blueprint.md#8-产品内置扩展与用户插件`](product-customization-blueprint.md#8-产品内置扩展与用户插件)。 完整能力状态、设计细节和阶段顺序分别见 @@ -325,8 +326,8 @@ flowchart LR | 产品形态 | 当前扩展能力 | 入口行为 | |---|---|---| -| Desktop / product-full | 生产入口仍依赖 `bitfun-core/product-full` 作为兼容组装层;“外部 AI 应用”设置已消费 OpenCode Command、standalone Tool 和 Subagent 的统一来源快照、审批、冲突选择与诊断 | 仅本地执行域支持当前三条纵向切片;受管 package plugin 仍只有静态预览,不能据设置页接入宣称完整 OpenCode 插件运行时 | -| CLI | 入口仍以 `bitfun-core/product-full` 作为执行兼容 owner;交互式 TUI 已可执行受支持的 Prompt Command,并通过 `/external-tools`、`/external-agents` 消费与 Desktop 相同的审批、冲突和刷新状态 | 已批准的 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 兼容门面转发;本切片不改变扩展执行边界 | +| 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 兼容门面转发;本切片不改变扩展执行边界 | | 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 本地能力 | @@ -355,8 +356,9 @@ flowchart LR 事件因果和审计;Slot 的 exclusive、ordered-chain、namespace-union、fallback 或 fan-out 语义必须由能力 owner 明确。 - TUI 与 GUI 不共享内部主题键、键位模型或界面状态;OpenCode TUI 原始键和组件只存在于适配层,转换后由 TUI 宿主消费,不能用构建期布局选择冒充运行时插件兼容。 -- 只有产品身份、安全恢复和法律要求等明确保护项不能被用户扩展覆盖;普通内置工具、命令和主题默认遵循 - OpenCode 兼容优先级。产品内置扩展不能复用用户来源批准或启用记录,产品签名也不能绕过运行时 +- 只有产品身份、安全恢复和法律要求等明确保护项不能被用户扩展覆盖;普通内置工具、命令和主题作为 BitFun + 来源候选保留,跨生态同名时由用户选择,不能按注册顺序静默决胜。冲突界面固定先展示 BitFun 候选,但展示顺序 + 不等于自动选择。产品内置扩展不能复用用户来源批准或启用记录,产品签名也不能绕过运行时 权限、审计和故障隔离。 - GUI/TUI 布局选择不复制主题 schema,不固化动态能力状态,也不携带可执行 UI 或任意构建脚本。 - 新 profile 只有在真实入口消费组装结果、能力可用性和类型化降级后才算接入;仅有枚举、空计划、re-export diff --git a/docs/architecture/product-customization-blueprint.md b/docs/architecture/product-customization-blueprint.md index 27c314376e..fc6d1d6bfc 100644 --- a/docs/architecture/product-customization-blueprint.md +++ b/docs/architecture/product-customization-blueprint.md @@ -167,7 +167,7 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I | 更新 | 随产品升级和回滚 | 用户或组织独立更新、停用和卸载 | 来源身份/完整性和更新策略允许时自动准备普通候选;软件包版本/完整性未获更新策略覆盖或能力扩大时等待确认;失败时只保留仍合规的健康旧进程,精确旧物化目录可校验时才重建 | | 权限 | 使用同一有效策略;直接脚本副作用受真实 OS/容器边界限制 | 同左 | 同左 | | 执行 | 与其他插件走同一进程隔离、期限、取消和恢复路径 | 同左 | 同左 | -| 冲突 | 普通贡献遵循目标生态顺序;少量产品保护项除外 | 按对应 BitFun 规则 | 按 OpenCode 顺序,可覆盖普通内置贡献 | +| 冲突 | 作为 BitFun 候选保留并在选择界面优先展示;少量产品保护项除外 | 与其他 BitFun 候选一并优先展示 | 生态内按 OpenCode 顺序;跨生态同名时由用户选择,不静默覆盖 | 管理、停用和更新使用包含生态、来源类型、规范化来源地址和 target 的来源限定运行实例身份;声明 `id` 只参与 生态识别和贡献覆盖,不能单独作为管理键。因此同名产品内置、BitFun 原生和 OpenCode 来源可以共存,且状态与 @@ -177,8 +177,8 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I 命令、主题和 Agent 不能仅因“随产品携带”成为保护项。发生保护冲突时,状态页必须显示被保护项、插件来源、 最终结果和替代入口,不能静默丢弃插件贡献。 -用户插件覆盖普通内置贡献只改变当前运行时的名称解析结果,不修改产品组装结果、已签名字节或内置扩展摘要。 -状态页必须同时保留内置来源、覆盖来源和恢复动作;停用覆盖来源后按同一生态顺序恢复内置贡献。保护清单只能 +用户明确选择插件覆盖普通内置贡献时,只改变当前运行时的名称解析结果,不修改产品组装结果、已签名字节或内置扩展摘要。 +状态页必须同时保留所有候选、选择结果和恢复动作;候选集合或行为版本变化后重新选择,不静默切换。保护清单只能 包含上段列出的具体系统项,不能用“产品已签名”把所有内置工具、命令、主题或 Agent 变成不可覆盖项。 必需内置扩展缺失或摘要不匹配时构建失败;运行时不可用时明确报告产品无法启动或功能降级。可选扩展失败不 @@ -211,5 +211,5 @@ GUI 主题由 Web/TS 主题模块定义,TUI 主题由 CLI/TUI 宿主定义,I 3. GUI/TUI 只消费各自布局和主题字段,一端字段不会进入另一端。 4. 主应用、CLI、安装器和 updater 使用一致的产品身份、更新渠道和签名公钥引用。 5. 两个产品的配置、日志、凭据引用、插件状态和更新状态保持隔离。 -6. 普通内置扩展遵循 OpenCode 覆盖顺序;保护冲突、停用、失败和回退可解释。 +6. 同名候选先展示 BitFun、再稳定展示其他生态;跨生态选择、保护冲突、停用、失败和恢复均可解释。 7. 用户配置和插件不能提高产品能力上限、改变产品身份或继承产品签名信任。 diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index 2d5262c20b..10ee8c5eb9 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -7,8 +7,10 @@ 能力进入 BitFun 的渐进导入轨道,不代表 BitFun 能力导出到 OpenCode 已经完成。兼容矩阵是审计库存,不是默认路线图。 PR1 已建立通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片,PR2 已把受支持的单文件 -`.js` standalone Tool 接入现有 Tool Runtime,PR3 已把 Subagent 安全子集交给现有 Subagent owner。BitFun 原有 -受管插件包来源确认和 custom tool 静态预览继续保留,但不等同于 OpenCode package plugin 可执行。三个切片均沿用 +`.js` standalone Tool 接入现有 Tool Runtime,PR3 已把 Subagent 安全子集交给现有 Subagent owner,PR4 补齐现有 +Skill 多来源的身份与覆盖状态展示。BitFun 原有 +受管插件包来源确认和 custom tool 静态预览继续保留,但不等同于 OpenCode package plugin 可执行。Command、Tool、Subagent +三个可执行切片均沿用 稳定的跨生态来源契约,不建设“大而全的 OpenCode Plugin Runtime”,也不提前承诺尚未执行的生态能力。 ## 1. 稳定架构基线 @@ -62,6 +64,7 @@ Product Assembly registers adapter implementations with the coordinator | PR1:来源目录 + OpenCode Command(已实现) | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;交互式 TUI(ChatMode)可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | | PR2:OpenCode standalone Tool(已实现) | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | | PR3:OpenCode Subagent(已实现) | 全局/项目 agent 定义经一次非阻塞确认后进入现有 Subagent owner,可选择、单次调用、更新和撤下;同名冲突由用户选择,unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 + generation lease | 原始 OpenCode 会话内核、完整 primary-agent 替换、外部 agent 续接、跨产品通用 agent JSON | +| PR4:Skill 来源与覆盖状态(本轮) | GUI/TUI 对已发现 Skill 显示生态来源;被现有优先级覆盖的同名项继续可见,并标明较高优先级来源;模式配置按实际选择结果标明当前覆盖来源 | 现有 Skill Registry;只补 provider-neutral 来源元数据和宿主展示 | 修改 Skill 优先级、引入审批弹窗、复制导入、URL/脚本执行、把 Skill 并入外部来源协调器 | Tool 与 Subagent 不复用 Command 的贡献对象,只复用来源身份、状态、代次、诊断和观察生命周期。未来接入 Codex 或 Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修改 OpenCode adapter 来容纳其他生态。 @@ -265,7 +268,47 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 代次租约、通用 watcher 事件限流、偏好记录压缩、完整 metrics/打点平台。它们分别涉及平台执行、安全控制面、 通用服务或数据保留策略,不能以“稳定性修复”为名并入本轮。 -## 6. 暂停条件 +## 6. PR4:Skill 来源与覆盖状态 + +PR4 只解释现有 Skill Registry 已经执行的选择结果,不改变选择本身。Skill 是历史上已经无感发现并按固定根顺序 +解析的声明式内容,与 Command、Tool、Subagent 等可执行扩展的首次接入和冲突确认不同;本轮不把两种策略强行合并。 + +### 6.1 优先级回归契约 + +- 项目根保持 `.bitfun`、`.claude`、`.codex`、`.cursor`、`.opencode`、`.agents` 的现有顺序;项目根整体先于 + 用户根。用户根、BitFun 用户目录、BitFun 内置目录、OpenCode config/home 延迟根继续沿用当前实现顺序。 +- “BitFun 优先”只适用于当前已经如此定义的项目级 `.bitfun/skills`,不得误写成所有 BitFun 用户或内置 Skill + 都高于其他生态。PR4 用精确顺序测试冻结这一事实,来源元数据不参与排序或决胜。 +- `source_slot` 继续标识具体发现槽位;新增开放的 `source_id` 和稳定产品名只负责把多个槽位归为 BitFun、 + Claude Code、Codex、Cursor、OpenCode 或 Agent Skills 来源。能力 owner 和界面不得根据 `source_id` 另算优先级。 +- 本地与远程项目继续消费同一项目根契约。远程工作区只扫描真实远程项目根,同时保留当前本机用户级 Skill 行为; + PR4 不借来源展示改变远程执行域或回退规则。 + +### 6.2 精简的 GUI/TUI 体验 + +- Skills 场景和设置列表沿用现有卡片/列表,在作用域旁显示来源。普通项不增加确认步骤;按默认优先关系被覆盖的项 + 保留在原位置,使用弱化名称、删除线和“已覆盖”状态,并标明较高优先级来源,不把无模式页面误写成某一模式 + “当前一定不会使用”。内部 stable key 不作为主要解释文案。 +- 交互式 TUI 的可用列表显示来源;配置列表继续使用既有勾选框,并把 `shadowed` 改为 + `covered by `。模式配置中的覆盖关系由 Skill owner 在应用模式禁用规则后输出;高优先级项在该模式被禁用时, + 实际采用的低优先级项不得仍显示为被前者覆盖。未保存的勾选变化只标为待保存,不预判新的运行时赢家。 +- GUI 与 TUI 都消费 Registry 返回的来源和覆盖事实,不解析路径猜测生态,不新增 system prompt 文本,也不建立 + 跨宿主渲染协议。来源展示元数据缺失时只用已知 `source_id`/`source_slot` 映射产品名,最终显示本地化的“其他来源”, + 不泄露内部槽位,也不能因为展示元数据异常隐藏可用 Skill。远程工作区同时标明“此设备 · 用户级”或 + “远程工作区 · 项目级”,不改变现有扫描与执行域。 +- 当前 Skill 刷新、删除和模式开关生命周期保持不变;本轮不增加 watcher、通知、持久化选择或重启提示。 + +### 6.3 后续可执行扩展的统一原则 + +Command、Tool、Subagent、MCP 以及未来可执行扩展默认把 BitFun 原生/内置实现作为安全候选,但不允许外部候选 +通过注册顺序静默覆盖。出现同名参与者时由用户选择;选择绑定参与者集合与行为版本,集合或行为更新后才重新询问。 +冲突选择列表固定先展示 BitFun 候选,其余生态按稳定 `provider_id` 排序,同一生态内部保留该 adapter 的正式来源顺序; +展示顺序只帮助用户理解,不代替选择,也不把 Skill 的固定根优先级复制到可执行扩展。 +被 BitFun/其他候选覆盖、被用户拒绝或尚未选择的外部项必须继续出现在统一管理入口,并以“已覆盖”“未启用”或 +“等待选择”及原因展示,不能从列表消失。该原则由各能力 owner 的独立冲突契约实现,不复用 Skill 的固定优先级, +也不在 PR4 修改 PR1—PR3 已有执行路径。 + +## 7. 暂停条件 出现以下情况时停止扩面并先修复架构: @@ -273,6 +316,6 @@ PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter - 为未来可能需求新增任意 payload 资产、通用脚本 SDK、第二套 Tool Runtime/Agent Runtime; - 只有静态解析却把 Tool、Hook、Subagent 或受限 Command 标为可用; - watcher 更新能绕过用户抑制,或一个 provider 的失败清空其他 provider; -- 同名候选仍由固定优先级静默选中,或候选内容版本变化后继续沿用旧冲突选择; +- Command、Tool、Subagent 等可执行扩展的同名候选仍由固定优先级静默选中,或候选内容版本变化后继续沿用旧冲突选择; - 为完整兼容一次性引入 package manager、Hook、renderer、Server 和权限系统; - 本地可用被直接推导为 Remote/HarmonyOS PC 可用,缺少同一 fixture 的真实运行证据。 diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index c628a1325c..135a2c9cfa 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -204,10 +204,13 @@ impl ChatMode { name: info.name, description: info.description, level: info.level.as_str().to_string(), + source_slot: info.source_slot, + source_label: info.source_label, enabled: true, selected_for_runtime: true, default_enabled: true, is_shadowed: info.is_shadowed, + shadowed_by_key: info.shadowed_by_key, } } @@ -217,10 +220,13 @@ impl ChatMode { name: info.skill.name, description: info.skill.description, level: info.skill.level.as_str().to_string(), + source_slot: info.skill.source_slot, + source_label: info.skill.source_label, enabled: info.effective_enabled, selected_for_runtime: info.selected_for_runtime, default_enabled: info.default_enabled, is_shadowed: info.skill.is_shadowed, + shadowed_by_key: info.skill.shadowed_by_key, } } diff --git a/src/apps/cli/src/ui/skill_selector.rs b/src/apps/cli/src/ui/skill_selector.rs index 1295529c10..65bb6fd58f 100644 --- a/src/apps/cli/src/ui/skill_selector.rs +++ b/src/apps/cli/src/ui/skill_selector.rs @@ -10,6 +10,7 @@ use ratatui::{ widgets::{Block, Borders, Clear, List, ListItem, ListState}, Frame, }; +use std::collections::HashMap; use crate::ui::theme::{StyleKind, Theme}; @@ -20,10 +21,58 @@ pub(crate) struct SkillItem { pub name: String, pub description: String, pub level: String, // "project" or "user" + pub source_slot: String, + pub source_label: String, pub enabled: bool, pub selected_for_runtime: bool, pub default_enabled: bool, pub is_shadowed: bool, + pub shadowed_by_key: Option, +} + +impl SkillItem { + fn display_source_label(&self) -> &str { + let label = self.source_label.trim(); + if !label.is_empty() { + return label; + } + + match self.source_slot.trim().trim_start_matches("home.") { + "bitfun" | "bitfun-system" => "BitFun", + "claude" => "Claude Code", + "codex" => "Codex", + "cursor" => "Cursor", + "opencode" | "config.opencode" => "OpenCode", + "agents" => "Agent Skills", + _ => "Other source", + } + } +} + +fn build_coverage_source_map(items: &[SkillItem]) -> HashMap { + let source_by_key: HashMap<&str, &str> = items + .iter() + .map(|item| (item.key.as_str(), item.display_source_label())) + .collect(); + + items + .iter() + .filter_map(|item| { + let winner_key = item.shadowed_by_key.as_deref()?; + let winner_source = source_by_key.get(winner_key)?; + Some((item.key.clone(), (*winner_source).to_string())) + }) + .collect() +} + +fn skill_checkbox_marker(skill: &SkillItem) -> &'static str { + if !skill.enabled { + "[ ] " + } else if skill.selected_for_runtime { + "[x] " + } else { + "[~] " + } } #[derive(Debug, Clone)] @@ -44,6 +93,7 @@ enum SkillSelectorScreen { /// Skill selector popup state pub(super) struct SkillSelectorState { items: Vec, + coverage_source_by_key: HashMap, list_state: ListState, visible: bool, last_area: Option, @@ -54,6 +104,7 @@ impl SkillSelectorState { pub(super) fn new() -> Self { Self { items: Vec::new(), + coverage_source_by_key: HashMap::new(), list_state: ListState::default(), visible: false, last_area: None, @@ -63,6 +114,7 @@ impl SkillSelectorState { pub(super) fn show_menu(&mut self) { self.items.clear(); + self.coverage_source_by_key.clear(); self.screen = SkillSelectorScreen::Menu; self.list_state.select(Some(0)); self.visible = true; @@ -74,6 +126,7 @@ impl SkillSelectorState { return; } + self.coverage_source_by_key = build_coverage_source_map(&skills); self.items = skills; self.screen = SkillSelectorScreen::List; self.list_state.select(Some(0)); @@ -99,6 +152,7 @@ impl SkillSelectorState { .and_then(|key| skills.iter().position(|item| item.key == key)) .unwrap_or_else(|| selected_index.min(skills.len().saturating_sub(1))); + self.coverage_source_by_key = build_coverage_source_map(&skills); self.items = skills; self.screen = SkillSelectorScreen::Configure; self.list_state.select(Some(next_index)); @@ -198,7 +252,8 @@ impl SkillSelectorState { }; self.last_area = Some(popup_area); - let list_items = self.render_items(theme); + let content_width = popup_width.saturating_sub(2); + let list_items = self.render_items(theme, content_width); let title = match self.screen { SkillSelectorScreen::Menu => " Skills ", SkillSelectorScreen::List => " List Skills (current mode) ", @@ -292,7 +347,7 @@ impl SkillSelectorState { Some(index) } - fn render_items(&self, theme: &Theme) -> Vec> { + fn render_items(&self, theme: &Theme, content_width: u16) -> Vec> { match self.screen { SkillSelectorScreen::Menu => vec![ ListItem::new(Line::from(vec![ @@ -321,12 +376,12 @@ impl SkillSelectorState { SkillSelectorScreen::List => self .items .iter() - .map(|skill| self.render_skill_line(skill, theme, false)) + .map(|skill| self.render_skill_line(skill, theme, false, content_width)) .collect(), SkillSelectorScreen::Configure => self .items .iter() - .map(|skill| self.render_skill_line(skill, theme, true)) + .map(|skill| self.render_skill_line(skill, theme, true, content_width)) .collect(), } } @@ -336,6 +391,7 @@ impl SkillSelectorState { skill: &SkillItem, theme: &Theme, include_checkbox: bool, + content_width: u16, ) -> ListItem<'static> { let level_marker = match skill.level.as_str() { "project" => "P", @@ -346,38 +402,156 @@ impl SkillSelectorState { "project" => theme.style(StyleKind::Info), _ => theme.style(StyleKind::Muted), }; - let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); + let name_style = if skill.is_shadowed { + theme + .style(StyleKind::Muted) + .add_modifier(Modifier::BOLD | Modifier::CROSSED_OUT) + } else { + theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + }; let desc_style = theme.style(StyleKind::Muted); + let compact = content_width < 40; let status = if skill.is_shadowed { - " shadowed" - } else if include_checkbox && skill.enabled && !skill.selected_for_runtime { - " enabled" + self.coverage_source_by_key + .get(&skill.key) + .map(|source| { + if compact { + format!(" < {}", compact_source_label(source)) + } else { + format!(" covered by {}", source) + } + }) + .unwrap_or_else(|| " covered".to_string()) + } else if include_checkbox && skill.enabled && skill.selected_for_runtime { + " active".to_string() + } else if include_checkbox && skill.enabled { + " enabled, not selected".to_string() } else { - "" + String::new() }; let mut spans = Vec::new(); if include_checkbox { + let checkbox_style = if !skill.enabled { + theme.style(StyleKind::Muted) + } else if skill.selected_for_runtime { + theme.style(StyleKind::Success) + } else { + theme.style(StyleKind::Warning) + }; + spans.push(Span::styled(skill_checkbox_marker(skill), checkbox_style)); + } + if !compact { spans.push(Span::styled( - if skill.enabled { "[x] " } else { "[ ] " }, - if skill.enabled { - theme.style(StyleKind::Success) - } else { - theme.style(StyleKind::Muted) - }, + format!("[{}/{}] ", level_marker, skill.display_source_label()), + level_style, )); } - spans.push(Span::styled(format!("[{}] ", level_marker), level_style)); spans.push(Span::styled(skill.name.clone(), name_style)); if !status.is_empty() { - spans.push(Span::styled( - status.to_string(), - theme.style(StyleKind::Muted), - )); + spans.push(Span::styled(status, theme.style(StyleKind::Muted))); + } + if !compact { + spans.push(Span::raw(" ")); + spans.push(Span::styled(skill.description.clone(), desc_style)); } - spans.push(Span::raw(" ")); - spans.push(Span::styled(skill.description.clone(), desc_style)); ListItem::new(Line::from(spans)) } } + +fn compact_source_label(source: &str) -> &str { + match source { + "Claude Code" => "Claude", + "Agent Skills" => "Agents", + _ => source, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{backend::TestBackend, Terminal}; + + fn skill_item(key: &str, source_label: &str) -> SkillItem { + SkillItem { + key: key.to_string(), + name: "pdf".to_string(), + description: String::new(), + level: "project".to_string(), + source_slot: "bitfun".to_string(), + source_label: source_label.to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: false, + shadowed_by_key: None, + } + } + + #[test] + fn skill_coverage_uses_winner_source_label() { + let winner = skill_item("project::bitfun::pdf", "BitFun"); + let mut covered = skill_item("user::home.codex::pdf", "Codex"); + covered.is_shadowed = true; + covered.shadowed_by_key = Some(winner.key.clone()); + + let coverage = build_coverage_source_map(&[covered.clone(), winner]); + assert_eq!( + coverage.get(&covered.key).map(String::as_str), + Some("BitFun") + ); + assert!(!build_coverage_source_map(&[covered.clone()]).contains_key(&covered.key)); + } + + #[test] + fn covered_enabled_skill_uses_an_indeterminate_checkbox_marker() { + let selected = skill_item("project::bitfun::pdf", "BitFun"); + let mut covered = skill_item("user::home.codex::pdf", "Codex"); + covered.selected_for_runtime = false; + covered.is_shadowed = true; + covered.shadowed_by_key = Some(selected.key.clone()); + + assert_eq!(skill_checkbox_marker(&selected), "[x] "); + assert_eq!(skill_checkbox_marker(&covered), "[~] "); + + let mut disabled = covered; + disabled.enabled = false; + assert_eq!(skill_checkbox_marker(&disabled), "[ ] "); + } + + #[test] + fn narrow_configuration_popup_keeps_name_and_coverage_visible() { + let selected = skill_item("project::bitfun::pdf", "BitFun"); + let mut covered = skill_item("user::home.claude::pdf", "Claude Code"); + covered.level = "user".to_string(); + covered.selected_for_runtime = false; + covered.is_shadowed = true; + covered.shadowed_by_key = Some(selected.key.clone()); + + let mut state = SkillSelectorState::new(); + state.show_config(vec![covered, selected]); + let mut terminal = Terminal::new(TestBackend::new(24, 8)).expect("test terminal"); + terminal + .draw(|frame| { + let area = frame.area(); + state.render(frame, area, &Theme::dark_ansi16()); + }) + .expect("render narrow skill 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("[~] pdf < BitFun"), + "narrow configuration should prioritize the skill name and coverage: {rendered:?}" + ); + } +} diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index c680e50d60..1bed3c1013 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1964,10 +1964,13 @@ impl StartupPage { name: info.name, description: info.description, level: info.level.as_str().to_string(), + source_slot: info.source_slot, + source_label: info.source_label, enabled: true, selected_for_runtime: true, default_enabled: true, is_shadowed: info.is_shadowed, + shadowed_by_key: info.shadowed_by_key, } } @@ -1977,10 +1980,13 @@ impl StartupPage { name: info.skill.name, description: info.skill.description, level: info.skill.level.as_str().to_string(), + source_slot: info.skill.source_slot, + source_label: info.skill.source_label, enabled: info.effective_enabled, selected_for_runtime: info.selected_for_runtime, default_enabled: info.default_enabled, is_shadowed: info.skill.is_shadowed, + shadowed_by_key: info.skill.shadowed_by_key, } } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 867170875e..570e5eb21b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -16,9 +16,10 @@ use bitfun_agent_runtime::skills::{ normalize_local_skill_dir_name, normalize_remote_skill_dir_name, normalize_skill_keys, resolve_default_hidden_builtin_for_explicit_invocation, resolve_user_config_skill_root, resolve_visible_skills, sort_skill_candidates_by_dir, sort_skills, - ExplicitSkillInvocationResolution, SkillCandidate, BITFUN_SYSTEM_SKILL_DIR, - BITFUN_SYSTEM_SKILL_SLOT, BITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, - PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, + ExplicitSkillInvocationResolution, SkillCandidate, BITFUN_SKILL_SOURCE_ID, + BITFUN_SKILL_SOURCE_LABEL, BITFUN_SYSTEM_SKILL_DIR, BITFUN_SYSTEM_SKILL_SLOT, + BITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, + USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; use log::{debug, error}; use std::collections::HashSet; @@ -35,6 +36,8 @@ struct SkillRootEntry { path: PathBuf, level: SkillLocation, slot: &'static str, + source_id: &'static str, + source_label: &'static str, priority: usize, is_builtin: bool, } @@ -43,6 +46,8 @@ struct SkillRootEntry { struct RemoteSkillRootEntry { path: String, slot: &'static str, + source_id: &'static str, + source_label: &'static str, priority: usize, } @@ -86,6 +91,8 @@ impl SkillRegistry { path, level: SkillLocation::Project, slot: spec.slot, + source_id: spec.source_id, + source_label: spec.source_label, priority, is_builtin: false, }); @@ -100,12 +107,19 @@ impl SkillRegistry { for spec in USER_HOME_SKILL_ROOTS { let path = home.join(spec.parent).join(spec.subdir); if spec.parent == ".opencode" { - deferred_home_entries.push((path, spec.slot)); + deferred_home_entries.push(( + path, + spec.slot, + spec.source_id, + spec.source_label, + )); } else if path.exists() && path.is_dir() { entries.push(SkillRootEntry { path, level: SkillLocation::User, slot: spec.slot, + source_id: spec.source_id, + source_label: spec.source_label, priority, is_builtin: false, }); @@ -124,6 +138,8 @@ impl SkillRegistry { path: bitfun_skills, level: SkillLocation::User, slot: BITFUN_USER_SKILL_SLOT, + source_id: BITFUN_SKILL_SOURCE_ID, + source_label: BITFUN_SKILL_SOURCE_LABEL, priority, is_builtin: false, }); @@ -136,6 +152,8 @@ impl SkillRegistry { path: builtin_skills, level: SkillLocation::User, slot: BITFUN_SYSTEM_SKILL_SLOT, + source_id: BITFUN_SKILL_SOURCE_ID, + source_label: BITFUN_SKILL_SOURCE_LABEL, priority, is_builtin: true, }); @@ -150,6 +168,8 @@ impl SkillRegistry { path, level: SkillLocation::User, slot: spec.slot, + source_id: spec.source_id, + source_label: spec.source_label, priority, is_builtin: false, }); @@ -158,12 +178,14 @@ impl SkillRegistry { } } - for (path, slot) in deferred_home_entries { + for (path, slot, source_id, source_label) in deferred_home_entries { if path.exists() && path.is_dir() { entries.push(SkillRootEntry { path, level: SkillLocation::User, slot, + source_id, + source_label, priority, is_builtin: false, }); @@ -219,6 +241,8 @@ impl SkillRegistry { skills.push(SkillCandidate::from_data( skill_data, entry.slot, + entry.source_id, + entry.source_label, key_prefix, entry.priority, entry.is_builtin, @@ -265,6 +289,8 @@ impl SkillRegistry { roots.push(RemoteSkillRootEntry { path, slot: spec.slot, + source_id: spec.source_id, + source_label: spec.source_label, priority, }); } @@ -303,6 +329,8 @@ impl SkillRegistry { skills.push(SkillCandidate::from_data( skill_data, entry.slot, + entry.source_id, + entry.source_label, PROJECT_SKILL_KEY_PREFIX, entry.priority, false, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs index 4488f5b74c..838de4907a 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/resolver.rs @@ -24,6 +24,8 @@ mod tests { path: format!("/tmp/{}", dir_name), level: SkillLocation::User, source_slot: "bitfun-system".to_string(), + source_id: "bitfun".to_string(), + source_label: "BitFun".to_string(), dir_name: dir_name.to_string(), is_builtin: true, group_key: None, @@ -40,6 +42,8 @@ mod tests { path: format!("/tmp/{}", dir_name), level: SkillLocation::User, source_slot: "bitfun".to_string(), + source_id: "bitfun".to_string(), + source_label: "BitFun".to_string(), dir_name: dir_name.to_string(), is_builtin: false, group_key: None, diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index 92f24816c7..7694e99717 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -21,9 +21,10 @@ pub use resolver::{ }; pub use roots::{ normalize_local_skill_dir_name, normalize_remote_skill_dir_name, - resolve_user_config_skill_root, SkillRootSpec, BITFUN_SYSTEM_SKILL_DIR, - BITFUN_SYSTEM_SKILL_SLOT, BITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, - PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, + resolve_user_config_skill_root, SkillRootSpec, BITFUN_SKILL_SOURCE_ID, + BITFUN_SKILL_SOURCE_LABEL, BITFUN_SYSTEM_SKILL_DIR, BITFUN_SYSTEM_SKILL_SLOT, + BITFUN_USER_SKILL_SLOT, PROJECT_SKILL_KEY_PREFIX, PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, + USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; pub use selection::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, diff --git a/src/crates/execution/agent-runtime/src/skills/roots.rs b/src/crates/execution/agent-runtime/src/skills/roots.rs index cddfb33bd7..c30490701a 100644 --- a/src/crates/execution/agent-runtime/src/skills/roots.rs +++ b/src/crates/execution/agent-runtime/src/skills/roots.rs @@ -5,12 +5,18 @@ pub const PROJECT_SKILL_KEY_PREFIX: &str = "project"; pub const BITFUN_USER_SKILL_SLOT: &str = "bitfun"; pub const BITFUN_SYSTEM_SKILL_SLOT: &str = "bitfun-system"; pub const BITFUN_SYSTEM_SKILL_DIR: &str = ".system"; +pub const BITFUN_SKILL_SOURCE_ID: &str = "bitfun"; +pub const BITFUN_SKILL_SOURCE_LABEL: &str = "BitFun"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SkillRootSpec { pub parent: &'static str, pub subdir: &'static str, pub slot: &'static str, + /// Provider-neutral ecosystem identity. It is presentation metadata only + /// and must never participate in skill precedence. + pub source_id: &'static str, + pub source_label: &'static str, } pub const PROJECT_SKILL_ROOTS: &[SkillRootSpec] = &[ @@ -18,31 +24,43 @@ pub const PROJECT_SKILL_ROOTS: &[SkillRootSpec] = &[ parent: ".bitfun", subdir: "skills", slot: "bitfun", + source_id: BITFUN_SKILL_SOURCE_ID, + source_label: BITFUN_SKILL_SOURCE_LABEL, }, SkillRootSpec { parent: ".claude", subdir: "skills", slot: "claude", + source_id: "claude-code", + source_label: "Claude Code", }, SkillRootSpec { parent: ".codex", subdir: "skills", slot: "codex", + source_id: "codex", + source_label: "Codex", }, SkillRootSpec { parent: ".cursor", subdir: "skills", slot: "cursor", + source_id: "cursor", + source_label: "Cursor", }, SkillRootSpec { parent: ".opencode", subdir: "skills", slot: "opencode", + source_id: "opencode", + source_label: "OpenCode", }, SkillRootSpec { parent: ".agents", subdir: "skills", slot: "agents", + source_id: "agent-skills", + source_label: "Agent Skills", }, ]; @@ -51,26 +69,36 @@ pub const USER_HOME_SKILL_ROOTS: &[SkillRootSpec] = &[ parent: ".claude", subdir: "skills", slot: "home.claude", + source_id: "claude-code", + source_label: "Claude Code", }, SkillRootSpec { parent: ".codex", subdir: "skills", slot: "home.codex", + source_id: "codex", + source_label: "Codex", }, SkillRootSpec { parent: ".cursor", subdir: "skills", slot: "home.cursor", + source_id: "cursor", + source_label: "Cursor", }, SkillRootSpec { parent: ".opencode", subdir: "skills", slot: "home.opencode", + source_id: "opencode", + source_label: "OpenCode", }, SkillRootSpec { parent: ".agents", subdir: "skills", slot: "home.agents", + source_id: "agent-skills", + source_label: "Agent Skills", }, ]; @@ -78,6 +106,8 @@ pub const USER_CONFIG_SKILL_ROOTS: &[SkillRootSpec] = &[SkillRootSpec { parent: "opencode", subdir: "skills", slot: "config.opencode", + source_id: "opencode", + source_label: "OpenCode", }]; pub fn resolve_user_config_skill_root( diff --git a/src/crates/execution/agent-runtime/src/skills/selection.rs b/src/crates/execution/agent-runtime/src/skills/selection.rs index fece663c8c..55703f9ddb 100644 --- a/src/crates/execution/agent-runtime/src/skills/selection.rs +++ b/src/crates/execution/agent-runtime/src/skills/selection.rs @@ -16,6 +16,8 @@ impl SkillCandidate { pub fn from_data( mut data: SkillData, slot: &str, + source_id: &str, + source_label: &str, key_prefix: &str, priority: usize, is_builtin: bool, @@ -36,6 +38,8 @@ impl SkillCandidate { path: data.path, level: data.location, source_slot: data.source_slot, + source_id: source_id.to_string(), + source_label: source_label.to_string(), dir_name: data.dir_name, is_builtin, group_key, @@ -165,7 +169,9 @@ pub fn annotate_shadowed_skills(candidates: Vec) -> Vec = HashMap::new(); for candidate in &candidates { match by_name.get(&candidate.info.name) { - Some(existing) if existing.priority <= candidate.priority => {} + Some(existing) + if skill_candidate_precedence(existing) + <= skill_candidate_precedence(candidate) => {} _ => { by_name.insert(candidate.info.name.clone(), candidate.clone()); } @@ -193,12 +199,16 @@ pub fn build_mode_skill_infos( user_overrides: &UserModeSkillOverrides, disabled_project_skills: &HashSet, ) -> Vec { + let resolved_by_name: HashMap = resolved_skills + .iter() + .map(|skill| (skill.name.clone(), skill.key.clone())) + .collect(); let resolved_keys: HashSet = resolved_skills.into_iter().map(|skill| skill.key).collect(); all_skills .into_iter() - .map(|skill| { + .map(|mut skill| { let state = resolve_skill_state_for_mode( &skill, mode_id, @@ -206,6 +216,15 @@ pub fn build_mode_skill_infos( disabled_project_skills, ); let selected_for_runtime = resolved_keys.contains(&skill.key); + let mode_winner_key = state + .effective_enabled + .then(|| resolved_by_name.get(&skill.name)) + .flatten() + .filter(|winner_key| **winner_key != skill.key) + .cloned(); + + skill.is_shadowed = mode_winner_key.is_some(); + skill.shadowed_by_key = mode_winner_key; ModeSkillInfo { skill, @@ -279,6 +298,8 @@ mod tests { path: path.to_string(), level: SkillLocation::Project, source_slot: String::new(), + source_id: String::new(), + source_label: String::new(), dir_name: name.to_string(), is_builtin: false, group_key: None, diff --git a/src/crates/execution/agent-runtime/src/skills/types.rs b/src/crates/execution/agent-runtime/src/skills/types.rs index 44532f26a1..0808cd1498 100644 --- a/src/crates/execution/agent-runtime/src/skills/types.rs +++ b/src/crates/execution/agent-runtime/src/skills/types.rs @@ -38,6 +38,12 @@ pub struct SkillInfo { pub path: String, pub level: SkillLocation, pub source_slot: String, + /// Ecosystem identity shared by all roots owned by the same source. + #[serde(default)] + pub source_id: String, + /// Stable product name supplied by the source definition. + #[serde(default)] + pub source_label: String, pub dir_name: String, #[serde(default)] pub is_builtin: bool, diff --git a/src/crates/execution/agent-runtime/tests/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/skill_contracts.rs index a616916397..d5a016ab6a 100644 --- a/src/crates/execution/agent-runtime/tests/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/skill_contracts.rs @@ -20,6 +20,8 @@ fn builtin_skill(dir_name: &str) -> SkillInfo { path: format!("/tmp/{}", dir_name), level: SkillLocation::User, source_slot: "bitfun-system".to_string(), + source_id: "bitfun".to_string(), + source_label: "BitFun".to_string(), dir_name: dir_name.to_string(), is_builtin: true, group_key: builtin_skill_group_key(dir_name).map(str::to_string), @@ -36,6 +38,8 @@ fn custom_user_skill(dir_name: &str) -> SkillInfo { path: format!("/tmp/{}", dir_name), level: SkillLocation::User, source_slot: "bitfun".to_string(), + source_id: "bitfun".to_string(), + source_label: "BitFun".to_string(), dir_name: dir_name.to_string(), is_builtin: false, group_key: None, @@ -52,6 +56,8 @@ fn project_skill(dir_name: &str) -> SkillInfo { path: format!("/workspace/.bitfun/skills/{}", dir_name), level: SkillLocation::Project, source_slot: "bitfun".to_string(), + source_id: "bitfun".to_string(), + source_label: "BitFun".to_string(), dir_name: dir_name.to_string(), is_builtin: false, group_key: None, @@ -122,29 +128,60 @@ fn skill_discovery_root_facts_are_runtime_owned() { assert_eq!(BITFUN_SYSTEM_SKILL_SLOT, "bitfun-system"); assert_eq!(BITFUN_SYSTEM_SKILL_DIR, ".system"); - assert!(PROJECT_SKILL_ROOTS + let project_roots = PROJECT_SKILL_ROOTS .iter() - .any(|root| root.parent == ".bitfun" && root.subdir == "skills" && root.slot == "bitfun")); - assert!(PROJECT_SKILL_ROOTS - .iter() - .any(|root| root.parent == ".opencode")); - assert!(USER_HOME_SKILL_ROOTS - .iter() - .any(|root| root.parent == ".codex" && root.slot == "home.codex")); - assert!(USER_HOME_SKILL_ROOTS - .iter() - .any(|root| root.parent == ".opencode" && root.slot == "home.opencode")); - assert!(USER_HOME_SKILL_ROOTS - .iter() - .any(|root| root.parent == ".agents" && root.slot == "home.agents")); - assert!(USER_CONFIG_SKILL_ROOTS + .map(|root| (root.parent, root.slot, root.source_id, root.source_label)) + .collect::>(); + assert_eq!( + project_roots, + [ + (".bitfun", "bitfun", "bitfun", "BitFun"), + (".claude", "claude", "claude-code", "Claude Code"), + (".codex", "codex", "codex", "Codex"), + (".cursor", "cursor", "cursor", "Cursor"), + (".opencode", "opencode", "opencode", "OpenCode"), + (".agents", "agents", "agent-skills", "Agent Skills"), + ] + ); + + let user_home_roots = USER_HOME_SKILL_ROOTS .iter() - .any(|root| root.parent == "opencode" && root.slot == "config.opencode")); + .map(|root| (root.parent, root.slot, root.source_id, root.source_label)) + .collect::>(); + assert_eq!( + user_home_roots, + [ + (".claude", "home.claude", "claude-code", "Claude Code"), + (".codex", "home.codex", "codex", "Codex"), + (".cursor", "home.cursor", "cursor", "Cursor"), + (".opencode", "home.opencode", "opencode", "OpenCode"), + (".agents", "home.agents", "agent-skills", "Agent Skills"), + ] + ); + assert_eq!( + USER_CONFIG_SKILL_ROOTS + .iter() + .map(|root| (root.parent, root.slot, root.source_id, root.source_label)) + .collect::>(), + [("opencode", "config.opencode", "opencode", "OpenCode")] + ); assert!(!USER_CONFIG_SKILL_ROOTS .iter() .any(|root| root.parent == "agents")); } +#[test] +fn skill_source_identity_is_serialized_without_changing_slot_identity() { + let mut info = project_skill("pdf"); + info.source_id = "bitfun".to_string(); + info.source_label = "BitFun".to_string(); + + let value = serde_json::to_value(info).expect("skill info should serialize"); + assert_eq!(value["sourceSlot"], "bitfun"); + assert_eq!(value["sourceId"], "bitfun"); + assert_eq!(value["sourceLabel"], "BitFun"); +} + #[test] fn user_config_skill_root_resolution_matches_platform_contract() { let opencode = USER_CONFIG_SKILL_ROOTS @@ -276,7 +313,8 @@ Use the pdf workflow. false, ) .expect("valid built-in skill markdown should parse"); - let candidate = SkillCandidate::from_data(data, "bitfun-system", "user", 10, true); + let candidate = + SkillCandidate::from_data(data, "bitfun-system", "bitfun", "BitFun", "user", 10, true); assert_eq!(candidate.info.key, "user::bitfun-system::pdf"); assert_eq!(candidate.info.source_slot, "bitfun-system"); @@ -302,6 +340,46 @@ Use the pdf workflow. ); } +#[test] +fn shadow_annotations_use_the_same_level_tiebreaker_as_runtime_resolution() { + let mut user_info = custom_user_skill("pdf"); + user_info.key = "user::home.claude::pdf".to_string(); + user_info.source_slot = "home.claude".to_string(); + user_info.source_id = "claude-code".to_string(); + user_info.source_label = "Claude Code".to_string(); + let user_candidate = SkillCandidate { + info: user_info, + priority: 0, + }; + let project_candidate = SkillCandidate { + info: project_skill("pdf"), + priority: 0, + }; + + let runtime_winner = + resolve_visible_skills(vec![user_candidate.clone(), project_candidate.clone()]); + assert_eq!(runtime_winner[0].key, project_candidate.info.key); + + let annotated = + annotate_shadowed_skills(vec![user_candidate.clone(), project_candidate.clone()]); + let user = annotated + .iter() + .find(|skill| skill.key == user_candidate.info.key) + .expect("user candidate should remain visible"); + let project = annotated + .iter() + .find(|skill| skill.key == project_candidate.info.key) + .expect("project candidate should remain visible"); + + assert!(user.is_shadowed); + assert_eq!( + user.shadowed_by_key.as_deref(), + Some(project_candidate.info.key.as_str()) + ); + assert!(!project.is_shadowed); + assert_eq!(project.shadowed_by_key, None); +} + #[test] fn mode_skill_candidate_filtering_and_info_are_runtime_owned() { let project_doc = SkillCandidate { @@ -360,6 +438,79 @@ fn mode_skill_candidate_filtering_and_info_are_runtime_owned() { ); } +#[test] +fn mode_skill_info_reports_the_actual_runtime_winner_after_filtering() { + let project_pdf = SkillCandidate { + info: project_skill("pdf"), + priority: 0, + }; + let mut codex_pdf_info = custom_user_skill("pdf"); + codex_pdf_info.key = "user::home.codex::pdf".to_string(); + codex_pdf_info.source_slot = "home.codex".to_string(); + codex_pdf_info.source_id = "codex".to_string(); + codex_pdf_info.source_label = "Codex".to_string(); + let codex_pdf = SkillCandidate { + info: codex_pdf_info, + priority: 10, + }; + let mut opencode_pdf_info = custom_user_skill("pdf"); + opencode_pdf_info.key = "user::home.opencode::pdf".to_string(); + opencode_pdf_info.source_slot = "home.opencode".to_string(); + opencode_pdf_info.source_id = "opencode".to_string(); + opencode_pdf_info.source_label = "OpenCode".to_string(); + let opencode_pdf = SkillCandidate { + info: opencode_pdf_info, + priority: 11, + }; + let candidates = vec![project_pdf.clone(), codex_pdf.clone(), opencode_pdf.clone()]; + let mut disabled_project = HashSet::new(); + disabled_project.insert(project_pdf.info.key.clone()); + + let filtered = filter_candidates_for_mode( + candidates.clone(), + "agentic", + &UserModeSkillOverrides::default(), + &disabled_project, + ); + let resolved = resolve_visible_skills(filtered); + let infos = build_mode_skill_infos( + sort_skills(annotate_shadowed_skills(candidates)), + resolved, + "agentic", + &UserModeSkillOverrides::default(), + &disabled_project, + ); + + let project = infos + .iter() + .find(|skill| skill.skill.key == project_pdf.info.key) + .expect("disabled project skill should stay visible"); + assert!(!project.effective_enabled); + assert!(!project.selected_for_runtime); + assert!(!project.skill.is_shadowed); + assert_eq!(project.skill.shadowed_by_key, None); + + let codex = infos + .iter() + .find(|skill| skill.skill.key == codex_pdf.info.key) + .expect("selected Codex skill should stay visible"); + assert!(codex.effective_enabled); + assert!(codex.selected_for_runtime); + assert!(!codex.skill.is_shadowed); + + let opencode = infos + .iter() + .find(|skill| skill.skill.key == opencode_pdf.info.key) + .expect("covered OpenCode skill should stay visible"); + assert!(opencode.effective_enabled); + assert!(!opencode.selected_for_runtime); + assert!(opencode.skill.is_shadowed); + assert_eq!( + opencode.skill.shadowed_by_key.as_deref(), + Some(codex_pdf.info.key.as_str()) + ); +} + #[test] fn explicit_invocation_hidden_builtin_fallback_is_runtime_owned() { let candidate = SkillCandidate { diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 936ff58ca8..d26d503a64 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -46,6 +46,11 @@ import { import { CustomAgentAPI } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeSkillInfo, SubagentModelSelection } from '@/infrastructure/config/types'; +import { + buildSkillCoverageSourceMap, + formatSkillOrigin, + getModeSkillRuntimeStatus, +} from '@/infrastructure/config/skillSourcePresentation'; import type { SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import { useNotification } from '@/shared/notification-system'; import { @@ -72,6 +77,7 @@ interface SkillGroup { label: string; skills: ModeSkillInfo[]; enabledCount: number; + selectedCount: number; totalCount: number; } @@ -121,15 +127,32 @@ function buildDuplicateSkillNameSet(skills: ModeSkillInfo[]): Set { ); } -function formatSkillOrigin(skill: ModeSkillInfo): string { - return `${skill.level}/${skill.sourceSlot}`; +function formatLocalizedSkillOrigin( + skill: ModeSkillInfo, + t: TFunction<'scenes/agents'>, + workspaceIsRemote: boolean, +): string { + return formatSkillOrigin(skill, { + fallbackSourceLabel: t('agentsOverview.unknownSkillSource'), + userLabel: workspaceIsRemote + ? t('agentsOverview.skillScopeLocalUser') + : t('agentsOverview.skillScopeUser'), + projectLabel: workspaceIsRemote + ? t('agentsOverview.skillScopeRemoteProject') + : t('agentsOverview.skillScopeProject'), + }); } -function formatSkillDisplayName(skill: ModeSkillInfo, duplicateNames: Set): string { +function formatSkillDisplayName( + skill: ModeSkillInfo, + duplicateNames: Set, + t: TFunction<'scenes/agents'>, + workspaceIsRemote: boolean, +): string { if (!duplicateNames.has(skill.name)) { return skill.name; } - return `${skill.name} [${formatSkillOrigin(skill)}]`; + return `${skill.name} [${formatLocalizedSkillOrigin(skill, t, workspaceIsRemote)}]`; } function getSkillGroupKey(skill: ModeSkillInfo): string { @@ -155,13 +178,38 @@ function getSkillGroupLabel(groupKey: string, t: TFunction<'scenes/agents'>): st } } -function getSkillTitle(skill: ModeSkillInfo, t: TFunction<'scenes/agents'>): string { +function getSkillRuntimeStatusLabel( + skill: ModeSkillInfo, + coverageSourceBySkillKey: ReadonlyMap, + t: TFunction<'scenes/agents'>, +): string | null { + const status = getModeSkillRuntimeStatus( + skill, + coverageSourceBySkillKey, + t('agentsOverview.unknownSkillSource'), + ); + switch (status.kind) { + case 'selected': + return t('agentsOverview.skillRuntimeSelected'); + case 'covered': + return t('agentsOverview.skillRuntimeCovered', { source: status.sourceLabel }); + case 'enabled': + return t('agentsOverview.skillRuntimeEnabled'); + case 'disabled': + return null; + } +} + +function getSkillTitle( + skill: ModeSkillInfo, + coverageSourceBySkillKey: ReadonlyMap, + t: TFunction<'scenes/agents'>, + workspaceIsRemote: boolean, +): string { return [ skill.description || skill.name, - `key: ${skill.key}`, - skill.effectiveEnabled && !skill.selectedForRuntime - ? t('agentsOverview.skillShadowed') - : null, + formatLocalizedSkillOrigin(skill, t, workspaceIsRemote), + getSkillRuntimeStatusLabel(skill, coverageSourceBySkillKey, t), ].filter(Boolean).join('\n'); } @@ -195,6 +243,7 @@ function buildSkillGroups( return a.name.localeCompare(b.name) || a.key.localeCompare(b.key); }), enabledCount: groupSkills.filter((skill) => enabledSkillKeySet.has(skill.key)).length, + selectedCount: groupSkills.filter((skill) => skill.selectedForRuntime).length, totalCount: groupSkills.length, })) .sort((a, b) => { @@ -239,6 +288,7 @@ const AgentsHomeView: React.FC = () => { const { workspacePath, + workspaceIsRemote = false, allAgents, filteredAgents, loading, @@ -410,6 +460,17 @@ const AgentsHomeView: React.FC = () => { () => buildDuplicateSkillNameSet(selectedAgentModeSkills), [selectedAgentModeSkills], ); + const selectedAgentCoverageSourceBySkillKey = useMemo( + () => buildSkillCoverageSourceMap( + selectedAgentModeSkills, + t('agentsOverview.unknownSkillSource'), + ), + [selectedAgentModeSkills, t], + ); + const selectedAgentRuntimeSkillCount = useMemo( + () => selectedAgentModeSkills.filter((skill) => skill.selectedForRuntime).length, + [selectedAgentModeSkills], + ); const selectedAgentProfileMemberNames = useMemo(() => { if (!selectedAgentModeProfile) { return []; @@ -514,7 +575,7 @@ const AgentsHomeView: React.FC = () => { key: 'skills', icon: Puzzle, label: t('agentsOverview.skills'), - count: `${(skillsEditing ? (pendingSkills ?? selectedAgentSkills) : selectedAgentSkills).length}/${selectedAgentModeSkills.length}`, + count: `${selectedAgentRuntimeSkillCount}/${selectedAgentModeSkills.length}`, }); } @@ -533,7 +594,6 @@ const AgentsHomeView: React.FC = () => { return tabs; }, [ userSelectableAvailableTools.length, - pendingSkills, pendingSubagentIds, pendingTools, selectedAgent, @@ -544,9 +604,8 @@ const AgentsHomeView: React.FC = () => { selectedAgentHasTaskTool, selectedAgentManageableSubagents.length, selectedAgentModeSkills.length, - selectedAgentSkills, + selectedAgentRuntimeSkillCount, selectedAgentTools, - skillsEditing, subagentsEditing, t, toolsEditing, @@ -896,7 +955,7 @@ const AgentsHomeView: React.FC = () => { {t('agentCard.meta.singleRun')} ) : null} {selectedAgent.agentKind === 'mode' && selectedAgentHasSkillTool ? ( - {t('agentCard.meta.skills', { count: selectedAgentSkills.length })} + {t('agentCard.meta.skills', { count: selectedAgentRuntimeSkillCount })} ) : null} {selectedAgent.agentKind === 'mode' && selectedAgentHasTaskTool ? ( {t('agentCard.meta.subagents', { count: selectedAgentManageableSubagents.filter((subagent) => subagent.effectiveEnabled).length })} @@ -1270,17 +1329,32 @@ const AgentsHomeView: React.FC = () => { const displayName = formatSkillDisplayName( skill, selectedAgentDuplicateSkillNames, + t, + workspaceIsRemote, + ); + const runtimeStatus = getModeSkillRuntimeStatus( + skill, + selectedAgentCoverageSourceBySkillKey, + t('agentsOverview.unknownSkillSource'), + ); + const runtimeStatusLabel = getSkillRuntimeStatusLabel( + skill, + selectedAgentCoverageSourceBySkillKey, + t, ); return ( ); })} @@ -1304,22 +1378,37 @@ const AgentsHomeView: React.FC = () => {
{group.label} - {group.enabledCount} + {group.selectedCount}
{group.skills .filter((skill) => skill.effectiveEnabled) - .map((skill) => ( - - {formatSkillDisplayName(skill, selectedAgentDuplicateSkillNames)} - - ))} + .map((skill) => { + const runtimeStatus = getModeSkillRuntimeStatus( + skill, + selectedAgentCoverageSourceBySkillKey, + t('agentsOverview.unknownSkillSource'), + ); + const runtimeStatusLabel = getSkillRuntimeStatusLabel( + skill, + selectedAgentCoverageSourceBySkillKey, + t, + ); + return ( + + {formatSkillDisplayName(skill, selectedAgentDuplicateSkillNames, t, workspaceIsRemote)} + {runtimeStatusLabel ? ( + {runtimeStatusLabel} + ) : null} + + ); + })}
)) diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.scss b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss index d4c991ee2f..a9aadbac4d 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentCard.scss +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss @@ -468,9 +468,29 @@ font-size: 11px; color: var(--color-text-secondary); white-space: nowrap; - max-width: 180px; + max-width: 320px; overflow: hidden; text-overflow: ellipsis; + + &.is-covered { + border-color: color-mix(in srgb, var(--color-warning) 32%, var(--border-subtle)); + color: var(--color-text-muted); + } + } + + &__chip-state, + &__token-state { + flex-shrink: 0; + margin-left: $size-gap-1; + font-family: $font-family-sans; + font-size: 10px; + font-weight: $font-weight-medium; + color: var(--color-text-muted); + } + + &__chip.is-covered &__chip-state, + &__token.is-covered &__token-state { + color: var(--color-warning); } // ── Editable token grid ────────────────────────────────────────────────── @@ -512,6 +532,12 @@ } } + &.is-covered { + border-color: color-mix(in srgb, var(--color-warning) 32%, var(--border-subtle)); + background: color-mix(in srgb, var(--color-warning) 8%, transparent); + color: var(--color-text-muted); + } + &.is-readonly, &:disabled { cursor: default; diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index f09846b3e8..ab1978d219 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -20,6 +20,7 @@ import { HIDDEN_AGENT_IDS, isAgentInOverviewZone } from '../agentVisibility'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; import { loadDefaultReviewTeamDefinition } from '@/shared/services/reviewTeamService'; import { globalEventBus } from '@/infrastructure/event-bus'; +import { isRemoteWorkspace } from '@/shared/types'; export type FilterLevel = 'all' | 'builtin' | 'user' | 'project' | 'external'; export type FilterType = 'all' | 'mode' | 'subagent'; @@ -162,7 +163,7 @@ export function useAgentsList({ t, }: UseAgentsListOptions) { const notification = useNotification(); - const { workspacePath } = useCurrentWorkspace(); + const { workspace, workspacePath } = useCurrentWorkspace(); const [allAgents, setAllAgents] = useState([]); const [loading, setLoading] = useState(true); const [availableTools, setAvailableTools] = useState([]); @@ -566,6 +567,7 @@ export function useAgentsList({ return { workspacePath, + workspaceIsRemote: isRemoteWorkspace(workspace), allAgents, filteredAgents, loading, diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.presentation.test.ts b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.presentation.test.ts new file mode 100644 index 0000000000..c834f36c12 --- /dev/null +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.presentation.test.ts @@ -0,0 +1,26 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readStylesheet(): string { + return readFileSync( + fileURLToPath(new URL('./NurseryView.scss', import.meta.url)), + 'utf8', + ).replace(/\r\n/g, '\n'); +} + +describe('Assistant defaults skill presentation', () => { + it('keeps covered state styling on skill rows and readable on narrow screens', () => { + const stylesheet = readStylesheet(); + const personaStart = stylesheet.indexOf('.tc-persona-doc-row {'); + const personaEnd = stylesheet.indexOf('.tc-persona-doc-editor', personaStart); + const skillStart = stylesheet.indexOf('.tc-skill-row {'); + const skillEnd = stylesheet.indexOf('.tc-hero {', skillStart); + + expect(stylesheet.slice(personaStart, personaEnd)).not.toContain('&--covered'); + + const skillSection = stylesheet.slice(skillStart, skillEnd); + expect(skillSection).toContain('&--covered'); + expect(skillSection).toMatch(/@media \(max-width: 720px\)[\s\S]*\.tc-skill-row[\s\S]*white-space: normal;/); + }); +}); diff --git a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx index be69f19a3c..38a0669bfc 100644 --- a/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/AssistantDefaultsPage.tsx @@ -15,6 +15,11 @@ import '@/app/components/GalleryLayout/GalleryLayout.scss'; import { Switch } from '@/component-library'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; import type { AgentProfileConfigItem, ModeSkillInfo } from '@/infrastructure/config/types'; +import { + buildSkillCoverageSourceMap, + formatSkillOrigin, + getModeSkillRuntimeStatus, +} from '@/infrastructure/config/skillSourcePresentation'; import { MCPAPI, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; import { notificationService } from '@/shared/notification-system'; import type { DynamicToolInfo } from '@/shared/types/agent-api'; @@ -61,15 +66,15 @@ function buildDuplicateSkillNameSet(skills: ModeSkillInfo[]): Set { ); } -function formatSkillOrigin(skill: ModeSkillInfo): string { - return `${skill.level}/${skill.sourceSlot}`; -} - -function formatSkillDisplayName(skill: ModeSkillInfo, duplicateNames: Set): string { +function formatSkillDisplayName( + skill: ModeSkillInfo, + duplicateNames: Set, + origin: string, +): string { if (!duplicateNames.has(skill.name)) { return skill.name; } - return `${skill.name} [${formatSkillOrigin(skill)}]`; + return `${skill.name} [${origin}]`; } const AssistantDefaultsPage: React.FC = () => { @@ -99,6 +104,39 @@ const AssistantDefaultsPage: React.FC = () => { () => buildDuplicateSkillNameSet(modeSkills), [modeSkills], ); + const coverageSourceBySkillKey = useMemo( + () => buildSkillCoverageSourceMap( + modeSkills, + t('nursery.template.unknownSkillSource'), + ), + [modeSkills, t], + ); + + const getLocalizedSkillOrigin = useCallback((skill: ModeSkillInfo) => ( + formatSkillOrigin(skill, { + fallbackSourceLabel: t('nursery.template.unknownSkillSource'), + userLabel: t('nursery.template.skillScopeUser'), + projectLabel: t('nursery.template.skillScopeProject'), + }) + ), [t]); + + const getSkillRuntimeStatusLabel = useCallback((skill: ModeSkillInfo): string | null => { + const status = getModeSkillRuntimeStatus( + skill, + coverageSourceBySkillKey, + t('nursery.template.unknownSkillSource'), + ); + switch (status.kind) { + case 'selected': + return t('nursery.template.skillRuntimeSelected'); + case 'covered': + return t('nursery.template.skillRuntimeCovered', { source: status.sourceLabel }); + case 'enabled': + return t('nursery.template.skillRuntimeEnabled'); + case 'disabled': + return null; + } + }, [coverageSourceBySkillKey, t]); const userSelectableTools = useMemo( () => availableTools.filter((tool) => isUserSelectableToolName(tool.name)), @@ -349,11 +387,18 @@ const AssistantDefaultsPage: React.FC = () => { {list.map((skill) => { const on = skill.effectiveEnabled; const selected = detail?.type === 'skill' && detail.skill.key === skill.key; - const displayName = formatSkillDisplayName(skill, duplicateSkillNames); + const origin = getLocalizedSkillOrigin(skill); + const displayName = formatSkillDisplayName(skill, duplicateSkillNames, origin); + const runtimeStatus = getModeSkillRuntimeStatus( + skill, + coverageSourceBySkillKey, + t('nursery.template.unknownSkillSource'), + ); + const runtimeStatusLabel = getSkillRuntimeStatusLabel(skill); return (
handleSkillToggle(skill)} disabled={skillsLoading[skill.key]} size="small" + aria-label={displayName} />
); @@ -378,7 +427,7 @@ const AssistantDefaultsPage: React.FC = () => { const renderSkillEnabledDisabledSplit = () => (
-

{t('nursery.template.colEnabled')}

+

{t('nursery.template.skillEnabledCandidates')}

{skillsEnabled.length > 0 ? ( renderSkillList(skillsEnabled) ) : ( @@ -561,6 +610,8 @@ const AssistantDefaultsPage: React.FC = () => { const { skill } = detail; const on = skill.effectiveEnabled; + const origin = getLocalizedSkillOrigin(skill); + const runtimeStatusLabel = getSkillRuntimeStatusLabel(skill); return (
diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryView.scss b/src/web-ui/src/app/scenes/profile/views/NurseryView.scss index 90535e3888..b37d03c354 100644 --- a/src/web-ui/src/app/scenes/profile/views/NurseryView.scss +++ b/src/web-ui/src/app/scenes/profile/views/NurseryView.scss @@ -2032,6 +2032,17 @@ $nursery-scene-gutter: clamp(40px, 6vw, 80px); background: var(--element-bg-subtle); } + &--covered { + .tc-skill-row__name { + color: var(--color-text-muted); + text-decoration: line-through; + } + + .tc-skill-row__state { + color: var(--color-warning); + } + } + &__hit { flex: 1; min-width: 0; @@ -2069,12 +2080,45 @@ $nursery-scene-gutter: clamp(40px, 6vw, 80px); font-weight: $font-weight-medium; } + &__state { + flex-shrink: 1; + min-width: 72px; + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; + color: var(--color-text-muted); + } + .bitfun-switch { flex-shrink: 0; margin-right: $size-gap-2; } } +@media (max-width: 720px) { + .tc-skill-row { + &__hit { + align-items: flex-start; + flex-wrap: wrap; + } + + &__name { + flex: 1 1 140px; + } + + &__state { + flex: 1 1 100%; + min-width: 0; + max-width: none; + overflow: visible; + text-overflow: clip; + white-space: normal; + } + } +} + // ── Hero panel ───────────────────────────────────────────────────────────── .tc-hero { diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.scss b/src/web-ui/src/app/scenes/skills/SkillsScene.scss index a788c28e95..35c94b8f35 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.scss +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.scss @@ -605,7 +605,12 @@ } &.is-shadowed { - text-decoration: line-through; + background: color-mix(in srgb, var(--color-warning) 10%, var(--element-bg-subtle)); + border-color: color-mix(in srgb, var(--color-warning) 30%, var(--border-subtle)); + + .skills-suite__skill-chip-name { + text-decoration: line-through; + } } &.is-dirty { @@ -622,6 +627,13 @@ white-space: nowrap; } +.skills-suite__skill-chip-status { + color: var(--color-warning); + font-size: 10px; + font-weight: $font-weight-medium; + white-space: nowrap; +} + @keyframes skills-suite-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 15776e5374..26500feb31 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -22,6 +22,11 @@ import { useTranslation } from 'react-i18next'; import { Badge, Button, ConfirmDialog, Input, Modal, Search, Select } from '@/component-library'; import { GalleryDetailModal } from '@/app/components'; import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; +import { + buildSkillCoverageSourceMap, + findSkillByKey, + getSkillSourceLabel, +} from '@/infrastructure/config/skillSourcePresentation'; import { workspaceAPI } from '@/infrastructure/api'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { useNotification } from '@/shared/notification-system'; @@ -79,7 +84,7 @@ const SkillsScene: React.FC = () => { const [installedListPage, setInstalledListPage] = useState(0); const [installedSearch, setInstalledSearch] = useState(''); const [selectedDetail, setSelectedDetail] = useState< - | { type: 'installed'; skill: SkillInfo } + | { type: 'installed'; skillKey: string } | { type: 'market'; skill: SkillMarketItem } | null >(null); @@ -93,6 +98,24 @@ const SkillsScene: React.FC = () => { () => new Set(installed.skills.map((skill) => skill.name)), [installed.skills], ); + const coverageSourceBySkillKey = useMemo( + () => buildSkillCoverageSourceMap(installed.skills, t('list.item.unknownSource')), + [installed.skills, t], + ); + const selectedInstalledSkill = useMemo( + () => findSkillByKey( + installed.skills, + selectedDetail?.type === 'installed' ? selectedDetail.skillKey : null, + ), + [installed.skills, selectedDetail], + ); + const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; + + useEffect(() => { + if (selectedDetail?.type === 'installed' && !installed.loading && !selectedInstalledSkill) { + setSelectedDetail(null); + } + }, [installed.loading, selectedDetail, selectedInstalledSkill]); const market = useSkillMarket({ searchQuery: marketQuery, @@ -137,9 +160,6 @@ const SkillsScene: React.FC = () => { } }; - const selectedInstalledSkill = selectedDetail?.type === 'installed' ? selectedDetail.skill : null; - const selectedMarketSkill = selectedDetail?.type === 'market' ? selectedDetail.skill : null; - const installedFiltered = useMemo(() => { const list = hideDuplicates ? installed.filteredSkills.filter((s) => !s.isShadowed) @@ -286,13 +306,13 @@ const SkillsScene: React.FC = () => { skill.isShadowed && 'is-shadowed', ].filter(Boolean).join(' ')} style={{ '--surface-stagger-index': index } as React.CSSProperties} - onClick={() => setSelectedDetail({ type: 'installed', skill })} + onClick={() => setSelectedDetail({ type: 'installed', skillKey: skill.key })} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); - setSelectedDetail({ type: 'installed', skill }); + setSelectedDetail({ type: 'installed', skillKey: skill.key }); } }} aria-label={skill.name} @@ -323,20 +343,32 @@ const SkillsScene: React.FC = () => {
{skill.isShadowed && ( - + {t('list.item.shadowed')} )} + + {getSkillSourceLabel(skill, t('list.item.unknownSource'))} + {skill.level === 'user' ? : } - {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} + {market.isRemoteWorkspace + ? skill.level === 'user' + ? t('list.item.localUser') + : t('list.item.remoteProject') + : skill.level === 'user' + ? t('list.item.user') + : t('list.item.project')} {skill.path && ( ); })} diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.scss b/src/web-ui/src/infrastructure/config/components/SkillsConfig.scss index 2895e541cc..342aaa48dd 100644 --- a/src/web-ui/src/infrastructure/config/components/SkillsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.scss @@ -67,6 +67,28 @@ word-break: break-all; } + &__source-badge { + color: var(--color-text-secondary); + } + + &__covered-badge { + color: var(--color-warning); + border-color: color-mix(in srgb, var(--color-warning) 32%, var(--border-subtle)); + background: color-mix(in srgb, var(--color-warning) 10%, var(--element-bg-medium)); + } + + &__item--covered { + .bitfun-collection-item__name { + color: var(--color-text-muted); + text-decoration: line-through; + } + + } + + &__coverage-detail { + color: var(--color-text-secondary); + } + &__market-toolbar { margin-bottom: $size-gap-3; diff --git a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx index 2ed6c3ad87..04dbc9bfde 100644 --- a/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SkillsConfig.tsx @@ -9,6 +9,10 @@ import { useNotification } from '@/shared/notification-system'; import { isRemoteWorkspace } from '@/shared/types'; import { configAPI } from '../../api/service-api/ConfigAPI'; import type { SkillInfo, SkillLevel, SkillMarketItem, SkillValidationResult } from '../types'; +import { + buildSkillCoverageSourceMap, + getSkillSourceLabel, +} from '../skillSourcePresentation'; import { open } from '@tauri-apps/plugin-dialog'; import { createLogger } from '@/shared/utils/logger'; import './SkillsConfig.scss'; @@ -40,6 +44,10 @@ const SkillsConfig: React.FC = () => { const [marketError, setMarketError] = useState(null); const [downloadingPackage, setDownloadingPackage] = useState(null); const loadRequestIdRef = useRef(0); + const coverageSourceBySkillKey = useMemo( + () => buildSkillCoverageSourceMap(skills, t('list.item.unknownSource')), + [skills, t], + ); const { workspace, workspacePath, hasWorkspace } = useCurrentWorkspace(); const isRemote = isRemoteWorkspace(workspace); @@ -277,10 +285,33 @@ const SkillsConfig: React.FC = () => { }; const renderSkillRow = (skill: SkillInfo) => { + const sourceLabel = getSkillSourceLabel(skill, t('list.item.unknownSource')); + const coverageSourceLabel = coverageSourceBySkillKey.get(skill.key); const badge = ( - - {skill.level === 'user' ? t('list.item.user') : t('list.item.project')} - + <> + + {isRemote + ? skill.level === 'user' + ? t('list.item.localUser') + : t('list.item.remoteProject') + : skill.level === 'user' + ? t('list.item.user') + : t('list.item.project')} + + + {sourceLabel} + + {skill.isShadowed && ( + + {t('list.item.shadowed')} + + )} + ); const control = ( <> @@ -297,6 +328,20 @@ const SkillsConfig: React.FC = () => { const details = ( <>
{skill.description}
+
+ {t('list.item.sourceLabel')} + {sourceLabel} +
+ {skill.isShadowed && ( +
+ {t('list.item.shadowedLabel')} + + {t('list.item.shadowedDetail', { + source: coverageSourceLabel ?? t('list.item.unknownSource'), + })} + +
+ )}
{t('list.item.pathLabel')} {skill.path} @@ -308,10 +353,12 @@ const SkillsConfig: React.FC = () => { key={skill.key} label={skill.name} badge={badge} + badgePlacement="below" control={control} details={details} expanded={expandedSkillIds.has(skill.key)} onToggle={() => toggleSkillExpanded(skill.key)} + className={skill.isShadowed ? 'bitfun-skills-config__item--covered' : undefined} /> ); }; diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts new file mode 100644 index 0000000000..3b7fda7336 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from 'vitest'; +import type { ModeSkillInfo, SkillInfo } from './types'; +import { + buildSkillCoverageSourceMap, + findSkillByKey, + formatSkillOrigin, + getModeSkillRuntimeStatus, + getSkillSourceLabel, +} from './skillSourcePresentation'; + +function skill(overrides: Partial = {}): SkillInfo { + return { + key: 'project::bitfun::pdf', + name: 'pdf', + description: 'PDF workflow', + path: '/workspace/.bitfun/skills/pdf', + level: 'project', + sourceSlot: 'bitfun', + sourceId: 'bitfun', + sourceLabel: 'BitFun', + dirName: 'pdf', + isBuiltin: false, + ...overrides, + }; +} + +function modeSkill(overrides: Partial = {}): ModeSkillInfo { + return { + ...skill(), + defaultEnabled: true, + effectiveEnabled: true, + disabledByMode: false, + selectedForRuntime: true, + stateReason: 'project_default_enabled', + ...overrides, + }; +} + +describe('skill source presentation', () => { + it('uses the stable source label and falls back to source identity facts', () => { + expect(getSkillSourceLabel(skill())).toBe('BitFun'); + expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: 'codex' }))).toBe('Codex'); + expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: '', sourceSlot: 'home.codex' }))).toBe('Codex'); + expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: '', sourceSlot: 'bitfun-system' }))).toBe('BitFun'); + expect(getSkillSourceLabel(skill({ sourceLabel: '', sourceId: '', sourceSlot: 'future' }), '其他来源')).toBe('其他来源'); + }); + + it('formats source and scope with surface-localized labels', () => { + expect(formatSkillOrigin(skill(), { + fallbackSourceLabel: '其他来源', + userLabel: '用户', + projectLabel: '项目', + })).toBe('BitFun · 项目'); + + expect(formatSkillOrigin(skill(), { + fallbackSourceLabel: 'Other source', + userLabel: 'This device · User', + projectLabel: 'Remote workspace · Project', + })).toBe('BitFun · Remote workspace · Project'); + }); + + it('explains a shadowed skill with the winner source instead of an internal key', () => { + const winner = skill(); + const covered = skill({ + key: 'user::home.codex::pdf', + level: 'user', + sourceSlot: 'home.codex', + sourceId: 'codex', + sourceLabel: 'Codex', + isShadowed: true, + shadowedByKey: winner.key, + }); + + expect(buildSkillCoverageSourceMap([covered, winner]).get(covered.key)).toBe('BitFun'); + expect(buildSkillCoverageSourceMap([covered]).has(covered.key)).toBe(false); + }); + + it('distinguishes runtime selection from enabled and covered configuration', () => { + const winner = modeSkill(); + const covered = modeSkill({ + key: 'user::home.codex::pdf', + level: 'user', + sourceSlot: 'home.codex', + sourceId: 'codex', + sourceLabel: 'Codex', + selectedForRuntime: false, + isShadowed: true, + shadowedByKey: winner.key, + }); + const coverage = buildSkillCoverageSourceMap([covered, winner]); + + expect(getModeSkillRuntimeStatus(winner, coverage)).toEqual({ kind: 'selected' }); + expect(getModeSkillRuntimeStatus(covered, coverage)).toEqual({ + kind: 'covered', + sourceLabel: 'BitFun', + }); + expect(getModeSkillRuntimeStatus(modeSkill({ + effectiveEnabled: false, + selectedForRuntime: false, + }), coverage)).toEqual({ kind: 'disabled' }); + }); + + it('resolves installed detail data from the latest skill snapshot', () => { + const previous = skill({ description: 'Old description', isShadowed: true }); + const current = skill({ description: 'Current description', isShadowed: false }); + + expect(findSkillByKey([current], previous.key)).toBe(current); + expect(findSkillByKey([], previous.key)).toBeNull(); + }); + +}); diff --git a/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts new file mode 100644 index 0000000000..3f45ac3d0d --- /dev/null +++ b/src/web-ui/src/infrastructure/config/skillSourcePresentation.ts @@ -0,0 +1,108 @@ +import type { ModeSkillInfo, SkillInfo } from './types'; + +const SOURCE_LABEL_BY_ID: Record = { + bitfun: 'BitFun', + 'bitfun-system': 'BitFun', + 'claude-code': 'Claude Code', + claude: 'Claude Code', + codex: 'Codex', + cursor: 'Cursor', + opencode: 'OpenCode', + 'agent-skills': 'Agent Skills', + agents: 'Agent Skills', +}; + +function knownSourceLabel(value: string | undefined): string | undefined { + const normalized = value?.trim().toLowerCase(); + if (!normalized) { + return undefined; + } + + return SOURCE_LABEL_BY_ID[normalized] + ?? SOURCE_LABEL_BY_ID[normalized.replace(/^home\./, '').replace(/^config\./, '')]; +} + +export function getSkillSourceLabel( + skill: SkillInfo, + fallbackLabel = 'Other source', +): string { + return skill.sourceLabel?.trim() + || knownSourceLabel(skill.sourceId) + || knownSourceLabel(skill.sourceSlot) + || fallbackLabel; +} + +export interface SkillOriginLabels { + fallbackSourceLabel: string; + userLabel: string; + projectLabel: string; +} + +const DEFAULT_ORIGIN_LABELS: SkillOriginLabels = { + fallbackSourceLabel: 'Other source', + userLabel: 'User', + projectLabel: 'Project', +}; + +export function formatSkillOrigin( + skill: SkillInfo, + labels: SkillOriginLabels = DEFAULT_ORIGIN_LABELS, +): string { + const scopeLabel = skill.level === 'project' ? labels.projectLabel : labels.userLabel; + return `${getSkillSourceLabel(skill, labels.fallbackSourceLabel)} · ${scopeLabel}`; +} + +export function buildSkillCoverageSourceMap( + allSkills: SkillInfo[], + fallbackLabel = 'Other source', +): Map { + const skillsByKey = new Map(allSkills.map((skill) => [skill.key, skill])); + const coverageSources = new Map(); + + for (const skill of allSkills) { + const winnerKey = skill.shadowedByKey?.trim(); + if (!skill.isShadowed || !winnerKey) { + continue; + } + + const winner = skillsByKey.get(winnerKey); + if (winner) { + coverageSources.set(skill.key, getSkillSourceLabel(winner, fallbackLabel)); + } + } + + return coverageSources; +} + +export type ModeSkillRuntimeStatus = + | { kind: 'selected' } + | { kind: 'covered'; sourceLabel: string } + | { kind: 'enabled' } + | { kind: 'disabled' }; + +export function getModeSkillRuntimeStatus( + skill: ModeSkillInfo, + coverageSourceBySkillKey: ReadonlyMap, + fallbackLabel = 'Other source', +): ModeSkillRuntimeStatus { + if (!skill.effectiveEnabled) { + return { kind: 'disabled' }; + } + if (skill.selectedForRuntime) { + return { kind: 'selected' }; + } + if (skill.isShadowed) { + return { + kind: 'covered', + sourceLabel: coverageSourceBySkillKey.get(skill.key) ?? fallbackLabel, + }; + } + return { kind: 'enabled' }; +} + +export function findSkillByKey(skills: SkillInfo[], skillKey: string | null): SkillInfo | null { + if (!skillKey) { + return null; + } + return skills.find((skill) => skill.key === skillKey) ?? null; +} diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index 685d03563e..bf3b6508d8 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -273,6 +273,10 @@ export interface SkillInfo { path: string; level: SkillLevel; sourceSlot: string; + /** Provider-neutral ecosystem identity shared by related discovery slots. */ + sourceId?: string; + /** Stable product name supplied by the skill source definition. */ + sourceLabel?: string; dirName: string; isBuiltin: boolean; groupKey?: string | null; diff --git a/src/web-ui/src/locales/en-US/scenes/agents.json b/src/web-ui/src/locales/en-US/scenes/agents.json index ccce7c6233..5122b014f4 100644 --- a/src/web-ui/src/locales/en-US/scenes/agents.json +++ b/src/web-ui/src/locales/en-US/scenes/agents.json @@ -124,7 +124,14 @@ "sharedProfileLabel": "Shared profile", "sharedProfileDefaultLabel": "Shared coding profile", "sharedProfileDescription": "Changes here also apply to: {{modes}}.", - "skillShadowed": "shadowed by a higher-priority skill with the same name", + "unknownSkillSource": "Other source", + "skillScopeUser": "User", + "skillScopeProject": "Project", + "skillScopeLocalUser": "This device · User", + "skillScopeRemoteProject": "Remote workspace · Project", + "skillRuntimeSelected": "Used at runtime", + "skillRuntimeCovered": "Enabled, covered by {{source}}", + "skillRuntimeEnabled": "Enabled, not currently selected", "noSkills": "No skills enabled", "skillGroups": { "computerUse": "Computer Use", diff --git a/src/web-ui/src/locales/en-US/scenes/profile.json b/src/web-ui/src/locales/en-US/scenes/profile.json index 48539dc88a..b4fbbe2d98 100644 --- a/src/web-ui/src/locales/en-US/scenes/profile.json +++ b/src/web-ui/src/locales/en-US/scenes/profile.json @@ -157,7 +157,14 @@ "toolTypeMcp": "MCP tool", "readonlyTool": "Read-only", "serverToolsHeading": "Tools on this server", - "skillLevel": "Level {{level}}" + "skillEnabledCandidates": "Enabled candidates", + "unknownSkillSource": "Other source", + "skillScopeUser": "User", + "skillScopeProject": "Project", + "skillRuntimeSelected": "Used at runtime", + "skillRuntimeCovered": "Enabled, covered by {{source}}", + "skillRuntimeEnabled": "Enabled, not currently selected", + "skillOrigin": "Source and scope: {{origin}}" }, "tabs": { diff --git a/src/web-ui/src/locales/en-US/scenes/skills.json b/src/web-ui/src/locales/en-US/scenes/skills.json index c212612e17..3aec26db01 100644 --- a/src/web-ui/src/locales/en-US/scenes/skills.json +++ b/src/web-ui/src/locales/en-US/scenes/skills.json @@ -148,6 +148,13 @@ "disabled": "Hidden", "partial": "Partial" }, + "skillState": { + "enabled": "Enabled for this mode", + "disabled": "Disabled for this mode", + "pending": "Unsaved", + "covered": "Covered · {{source}}", + "coveredDetail": "Enabled, but this mode uses the same-name skill from {{source}}." + }, "groupCount": "{{total}} skills", "messages": { "saveSuccess": "Updated {{mode}} suite visibility", @@ -192,17 +199,21 @@ "item": { "user": "User", "project": "Project", + "localUser": "This device · User", + "remoteProject": "Remote workspace · Project", "builtin": "Built-in", "userInstalled": "Installed", "detail": "Details", "deleteTooltip": "Delete", "pathLabel": "Path:", + "sourceLabel": "Source:", "openPathInExplorer": "Open this folder in file explorer", "revealPathUnavailableRemote": "Remote workspace paths cannot be opened in the local file manager", - "shadowed": "Duplicate", - "shadowedTooltip": "This skill is shadowed by a higher-priority skill with the same name and will not be used.", - "shadowedLabel": "Shadowed by:", - "shadowedDetail": "Skill key {{key}}" + "shadowed": "Covered", + "shadowedTooltip": "The same-name skill from {{source}} normally has higher priority.", + "shadowedLabel": "Higher-priority source:", + "shadowedDetail": "The same-name skill from {{source}}", + "unknownSource": "another source" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/en-US/settings/skills.json b/src/web-ui/src/locales/en-US/settings/skills.json index 3714fe9a76..4e240decf9 100644 --- a/src/web-ui/src/locales/en-US/settings/skills.json +++ b/src/web-ui/src/locales/en-US/settings/skills.json @@ -66,8 +66,16 @@ "item": { "user": "User", "project": "Project", + "localUser": "This device · User", + "remoteProject": "Remote workspace · Project", "deleteTooltip": "Delete", - "pathLabel": "Path:" + "pathLabel": "Path:", + "sourceLabel": "Source:", + "shadowed": "Covered", + "shadowedTooltip": "The same-name skill from {{source}} normally has higher priority.", + "shadowedLabel": "Higher-priority source:", + "shadowedDetail": "The same-name skill from {{source}}", + "unknownSource": "another source" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-CN/scenes/agents.json b/src/web-ui/src/locales/zh-CN/scenes/agents.json index 0aa2eacffd..bd04091ff1 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/agents.json +++ b/src/web-ui/src/locales/zh-CN/scenes/agents.json @@ -124,7 +124,14 @@ "sharedProfileLabel": "共享配置", "sharedProfileDefaultLabel": "共享编码配置", "sharedProfileDescription": "这里的改动也会同时应用到:{{modes}}。", - "skillShadowed": "该 Skill 被同名且优先级更高的 Skill 遮蔽,运行时不会生效", + "unknownSkillSource": "其他来源", + "skillScopeUser": "用户", + "skillScopeProject": "项目", + "skillScopeLocalUser": "本机 · 用户", + "skillScopeRemoteProject": "远程工作区 · 项目", + "skillRuntimeSelected": "运行时采用", + "skillRuntimeCovered": "已启用,被 {{source}} 覆盖", + "skillRuntimeEnabled": "已启用,当前未采用", "noSkills": "未启用任何 Skill", "skillGroups": { "computerUse": "Computer Use", diff --git a/src/web-ui/src/locales/zh-CN/scenes/profile.json b/src/web-ui/src/locales/zh-CN/scenes/profile.json index f507130912..511dd5fa68 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/profile.json +++ b/src/web-ui/src/locales/zh-CN/scenes/profile.json @@ -157,7 +157,14 @@ "toolTypeMcp": "MCP 工具", "readonlyTool": "只读", "serverToolsHeading": "该服务下的工具", - "skillLevel": "级别 {{level}}" + "skillEnabledCandidates": "已启用候选项", + "unknownSkillSource": "其他来源", + "skillScopeUser": "用户", + "skillScopeProject": "项目", + "skillRuntimeSelected": "运行时采用", + "skillRuntimeCovered": "已启用,被 {{source}} 覆盖", + "skillRuntimeEnabled": "已启用,当前未采用", + "skillOrigin": "来源与范围:{{origin}}" }, "tabs": { diff --git a/src/web-ui/src/locales/zh-CN/scenes/skills.json b/src/web-ui/src/locales/zh-CN/scenes/skills.json index d6318b1d3a..b9476f0dc0 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/skills.json +++ b/src/web-ui/src/locales/zh-CN/scenes/skills.json @@ -148,6 +148,13 @@ "disabled": "隐藏", "partial": "部分" }, + "skillState": { + "enabled": "已为此模式启用", + "disabled": "已为此模式禁用", + "pending": "未保存", + "covered": "已覆盖 · {{source}}", + "coveredDetail": "已启用,但此模式会使用 {{source}} 中的同名技能。" + }, "groupCount": "{{total}} 个", "messages": { "saveSuccess": "已更新 {{mode}} 套件可见性", @@ -192,17 +199,21 @@ "item": { "user": "用户级", "project": "项目级", + "localUser": "此设备 · 用户级", + "remoteProject": "远程工作区 · 项目级", "builtin": "内置", "userInstalled": "已安装", "detail": "详情", "deleteTooltip": "删除", "pathLabel": "路径:", + "sourceLabel": "来源:", "openPathInExplorer": "在资源管理器中打开此文件夹", "revealPathUnavailableRemote": "远程工作区无法在本地资源管理器中打开路径", - "shadowed": "重复", - "shadowedTooltip": "该技能被更高优先级的同名技能覆盖,不会生效。", - "shadowedLabel": "被覆盖自:", - "shadowedDetail": "技能 key {{key}}" + "shadowed": "已覆盖", + "shadowedTooltip": "{{source}} 中的同名技能通常具有更高优先级。", + "shadowedLabel": "更高优先级来源:", + "shadowedDetail": "{{source}} 中的同名技能", + "unknownSource": "其他来源" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-CN/settings/skills.json b/src/web-ui/src/locales/zh-CN/settings/skills.json index 8a24286a57..4c376802a6 100644 --- a/src/web-ui/src/locales/zh-CN/settings/skills.json +++ b/src/web-ui/src/locales/zh-CN/settings/skills.json @@ -66,8 +66,16 @@ "item": { "user": "用户级", "project": "项目级", + "localUser": "此设备 · 用户级", + "remoteProject": "远程工作区 · 项目级", "deleteTooltip": "删除", - "pathLabel": "路径:" + "pathLabel": "路径:", + "sourceLabel": "来源:", + "shadowed": "已覆盖", + "shadowedTooltip": "{{source}} 中的同名技能通常具有更高优先级。", + "shadowedLabel": "更高优先级来源:", + "shadowedDetail": "{{source}} 中的同名技能", + "unknownSource": "其他来源" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-TW/scenes/agents.json b/src/web-ui/src/locales/zh-TW/scenes/agents.json index 4657b59018..702a3ec711 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/agents.json +++ b/src/web-ui/src/locales/zh-TW/scenes/agents.json @@ -124,7 +124,14 @@ "sharedProfileLabel": "共享設定", "sharedProfileDefaultLabel": "共享編碼設定", "sharedProfileDescription": "這裡的改動也會同時套用到:{{modes}}。", - "skillShadowed": "該 Skill 被同名且優先級更高的 Skill 遮蔽,運行時不會生效", + "unknownSkillSource": "其他來源", + "skillScopeUser": "使用者", + "skillScopeProject": "專案", + "skillScopeLocalUser": "本機 · 使用者", + "skillScopeRemoteProject": "遠端工作區 · 專案", + "skillRuntimeSelected": "執行時採用", + "skillRuntimeCovered": "已啟用,被 {{source}} 覆蓋", + "skillRuntimeEnabled": "已啟用,目前未採用", "noSkills": "未啟用任何 Skill", "skillGroups": { "computerUse": "Computer Use", diff --git a/src/web-ui/src/locales/zh-TW/scenes/profile.json b/src/web-ui/src/locales/zh-TW/scenes/profile.json index c96f12a7b3..3257a5531e 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/profile.json +++ b/src/web-ui/src/locales/zh-TW/scenes/profile.json @@ -157,7 +157,14 @@ "toolTypeMcp": "MCP 工具", "readonlyTool": "只讀", "serverToolsHeading": "該服務下的工具", - "skillLevel": "級別 {{level}}" + "skillEnabledCandidates": "已啟用候選項", + "unknownSkillSource": "其他來源", + "skillScopeUser": "使用者", + "skillScopeProject": "專案", + "skillRuntimeSelected": "執行時採用", + "skillRuntimeCovered": "已啟用,被 {{source}} 覆蓋", + "skillRuntimeEnabled": "已啟用,目前未採用", + "skillOrigin": "來源與範圍:{{origin}}" }, "tabs": { diff --git a/src/web-ui/src/locales/zh-TW/scenes/skills.json b/src/web-ui/src/locales/zh-TW/scenes/skills.json index 93e66b9687..cecbc8530a 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/skills.json +++ b/src/web-ui/src/locales/zh-TW/scenes/skills.json @@ -148,6 +148,13 @@ "disabled": "隱藏", "partial": "部分" }, + "skillState": { + "enabled": "已為此模式啟用", + "disabled": "已為此模式停用", + "pending": "未儲存", + "covered": "已覆蓋 · {{source}}", + "coveredDetail": "已啟用,但此模式會使用 {{source}} 中的同名技能。" + }, "groupCount": "{{total}} 個", "messages": { "saveSuccess": "已更新 {{mode}} 套件可見性", @@ -192,17 +199,21 @@ "item": { "user": "用戶級", "project": "項目級", + "localUser": "此裝置 · 用戶級", + "remoteProject": "遠端工作區 · 項目級", "builtin": "內建", "userInstalled": "已安裝", "detail": "詳情", "deleteTooltip": "刪除", "pathLabel": "路徑:", + "sourceLabel": "來源:", "openPathInExplorer": "在資源管理器中開啟此資料夾", "revealPathUnavailableRemote": "遠程工作區無法在本地資源管理器中開啟路徑", - "shadowed": "重複", - "shadowedTooltip": "該技能被更高優先級的同名技能覆蓋,不會生效。", - "shadowedLabel": "被覆蓋自:", - "shadowedDetail": "技能 key {{key}}" + "shadowed": "已覆蓋", + "shadowedTooltip": "{{source}} 中的同名技能通常具有更高優先順序。", + "shadowedLabel": "更高優先順序來源:", + "shadowedDetail": "{{source}} 中的同名技能", + "unknownSource": "其他來源" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-TW/settings/skills.json b/src/web-ui/src/locales/zh-TW/settings/skills.json index 288e975d9f..15bc6edd36 100644 --- a/src/web-ui/src/locales/zh-TW/settings/skills.json +++ b/src/web-ui/src/locales/zh-TW/settings/skills.json @@ -66,8 +66,16 @@ "item": { "user": "用戶級", "project": "項目級", + "localUser": "此裝置 · 用戶級", + "remoteProject": "遠端工作區 · 項目級", "deleteTooltip": "刪除", - "pathLabel": "路徑:" + "pathLabel": "路徑:", + "sourceLabel": "來源:", + "shadowed": "已覆蓋", + "shadowedTooltip": "{{source}} 中的同名技能通常具有更高優先順序。", + "shadowedLabel": "更高優先順序來源:", + "shadowedDetail": "{{source}} 中的同名技能", + "unknownSource": "其他來源" } }, "deleteModal": {