From 084ed9cff4a26c6727922ae927ebb3b9b4b6fc3a Mon Sep 17 00:00:00 2001 From: limityan Date: Sat, 18 Jul 2026 01:39:55 +0800 Subject: [PATCH] refactor(runtime): unify model updates and modularize TUI chat Route session model mutations through the Agent Runtime SDK while keeping Core as the sole owner. Split chat mode by private responsibilities and debounce resize redraws without changing public TUI contracts. --- .../agent-runtime-services-design.md | 33 +- docs/architecture/cli-product-line-design.md | 28 +- docs/architecture/product-architecture.md | 2 +- docs/plans/core-decomposition-plan.md | 10 +- .../product-architecture-evolution-plan.md | 16 +- src/apps/cli/src/agent/core_adapter.rs | 26 +- src/apps/cli/src/modes/chat.rs | 5715 +---------------- src/apps/cli/src/modes/chat/account.rs | 254 + src/apps/cli/src/modes/chat/capabilities.rs | 399 ++ src/apps/cli/src/modes/chat/commands.rs | 744 +++ .../cli/src/modes/chat/external_review.rs | 567 ++ .../cli/src/modes/chat/external_sources.rs | 525 ++ src/apps/cli/src/modes/chat/input.rs | 616 ++ src/apps/cli/src/modes/chat/mcp.rs | 504 ++ .../cli/src/modes/chat/provider_models.rs | 259 + src/apps/cli/src/modes/chat/resize.rs | 143 + src/apps/cli/src/modes/chat/run.rs | 626 ++ src/apps/cli/src/modes/chat/selection.rs | 497 ++ src/apps/cli/src/modes/chat/sessions.rs | 210 + src/apps/cli/src/modes/chat/tests.rs | 612 ++ .../cli/src/peer_host/commands/session.rs | 13 +- src/apps/desktop/src/api/agentic_api.rs | 17 +- src/apps/desktop/src/runtime/mod.rs | 5 +- .../src/agentic/coordination/coordinator.rs | 84 + .../src/agentic/session/session_manager.rs | 33 + .../assembly/core/src/product_runtime.rs | 6 - .../remote_connect/bot/command_router.rs | 10 + .../core/src/service_agent_runtime.rs | 26 +- src/crates/contracts/runtime-ports/src/lib.rs | 20 + .../execution/agent-runtime/src/runtime.rs | 48 +- src/crates/execution/agent-runtime/src/sdk.rs | 32 +- .../agent-runtime/tests/session_model_sdk.rs | 101 + .../execution/tool-contracts/src/framework.rs | 2 +- .../interfaces/acp/src/runtime/model.rs | 21 +- .../tests/remote_connect_contracts.rs | 1 + 35 files changed, 6416 insertions(+), 5789 deletions(-) create mode 100644 src/apps/cli/src/modes/chat/account.rs create mode 100644 src/apps/cli/src/modes/chat/capabilities.rs create mode 100644 src/apps/cli/src/modes/chat/commands.rs create mode 100644 src/apps/cli/src/modes/chat/external_review.rs create mode 100644 src/apps/cli/src/modes/chat/external_sources.rs create mode 100644 src/apps/cli/src/modes/chat/input.rs create mode 100644 src/apps/cli/src/modes/chat/mcp.rs create mode 100644 src/apps/cli/src/modes/chat/provider_models.rs create mode 100644 src/apps/cli/src/modes/chat/resize.rs create mode 100644 src/apps/cli/src/modes/chat/run.rs create mode 100644 src/apps/cli/src/modes/chat/selection.rs create mode 100644 src/apps/cli/src/modes/chat/sessions.rs create mode 100644 src/apps/cli/src/modes/chat/tests.rs create mode 100644 src/crates/execution/agent-runtime/tests/session_model_sdk.rs diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index 6050c250e4..bb40b06759 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -13,8 +13,8 @@ CLI Agent 体验边界见 [`cli-product-line-design.md`](cli-product-line-design 新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 Agent Runtime SDK 仍是 v1 preview,CLI、ACP、 Desktop 仍保留 `bitfun-core/product-full` 兼容 owner。CLI 与 CLI 托管的 ACP server 已消费各自的产品组装结果; Desktop 主交互只消费由现有 Core owner 构造的窄口径 SDK 门面,尚未组装完整 Desktop profile。这些接入都不等于 -协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模型/模式、MCP、客户端路径与 Desktop -的其余入口仍保留明确的兼容边界。 +协调器、调度器、持久化或工具执行 owner 已迁移;ACP 的完整持久化历史、模式、模型目录/提供方配置、MCP、客户端路径与 +Desktop 的其余入口仍保留明确的兼容边界,活动会话的模型写入已通过 SDK 回到 Core owner。 阅读路径:第 1 节确认 SDK、内核、产品特性、扩展接口和 crate 边界;第 2-3 节说明稳定接口、 运行时服务、内核、工具和工作流;第 4 节说明产品组装与扩展注册;第 5 节作为质量保护和 @@ -708,19 +708,20 @@ pub struct HarnessExecutionContext { `DeliveryProfile::Acp` 构造 `ProductRuntimeParts`。Desktop 主交互直接从现有协调器和调度器端口构造窄口径 Agent Runtime SDK 门面,不注册未实现的 `RuntimeServices` 能力,也不宣称完整 Desktop profile 可用。CLI 通过 一个调用级上下文把 Agent Runtime SDK、Harness、能力注册、调用级权限和 Agentic 事件广播交给 TUI、Exec、Session、Usage 与 -交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、类型化转录读取、轮次提交/取消,以及 CLI/TUI +交互模式下的 Peer Host。SDK 已承接会话创建/列举/删除/恢复、会话模型更新、类型化转录读取、轮次提交/取消,以及 CLI/TUI 的工具确认、拒绝和用户问题回答;固定 ID 创建使用独立的 `create_session_with_id` 方法,普通创建 DTO 保持 v1 字段集合。未实现该能力的提供方返回类型化不支持错误;实现成功时 Runtime 必须校验返回 ID 与请求完全一致,不能 -替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 当前只消费同一 SDK 的工具确认与拒绝。 -SDK v1 尚未覆盖的模型更新、分支、用量、快照和 -持久化维护等操作仍由 `assembly/core` 的单一兼容门面转发。`doctor` 与 `health` 校验真实组装结果及必需注册完整性; +替换为自动生成的 ID。`SessionSelector::Create` 仍保持自动生成。Peer Host 通过同一 SDK 处理对话提交、精确取消、 +工具确认、拒绝和会话模型更新。SDK v1 尚未覆盖的分支、用量、快照和持久化维护等操作仍由 +`assembly/core` 的单一兼容门面转发。 +`doctor` 与 `health` 校验真实组装结果及必需注册完整性; Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊断不等于对这些外部服务做实时探活。 该切换仍是 `product-full` 兼容组装,不是 owner 迁移。协调器、调度器、持久化、工具管线和 Agentic Event Queue 仍由 Core 唯一持有;CLI 与 ACP 不复制这些状态。ACP 服务端通过 SDK 处理会话创建/列举、轮次、取消、交互响应和事件订阅, -但完整持久化历史回放、模型/模式、模型目录/配置和 MCP 仍走单一 Core 兼容门面;ACP stdio、连接和协议投影仍在 +但完整持久化历史回放、模式、模型目录/配置和 MCP 仍走单一 Core 兼容门面;会话模型写入通过 SDK 回到同一 Core owner。ACP stdio、连接和协议投影仍在 `interfaces/acp`。Desktop 复用同一 Core owner 构造一个窄口径 SDK 门面,主界面的轮次提交/取消、工具确认/拒绝和 -用户问题回答已通过该门面;会话 CRUD/恢复视图、模型、MCP、MiniApp、Cron、远程连接、Tauri 窗口与平台资源 +用户问题回答与会话模型更新已通过该门面;会话 CRUD/恢复视图、MCP、MiniApp、Cron、远程连接、Tauri 窗口与平台资源 仍保留在 Desktop/Core 兼容入口。Server 仅提供健康检查、信息与 ping 路由。未接入入口的 profile、枚举分支和 单元测试仍不能证明对应产品形态可用。 @@ -794,14 +795,15 @@ Core 的 Network、Git 和 MCP Catalog 当前仍含兼容 marker,因此该诊 | ACP | ACP 协议、客户端生命周期、远端探测 | 外部智能体/工具能力、环境事实、权限桥接 | | Web UI / mobile web | UI 状态、hydration、配对、会话展示、插件状态视图 | 接口/传输 DTO、运行时事件事实、能力服务读模型 | -当前 Runtime SDK 已提供会话创建、列出、删除、恢复和类型化转录读取。`AgentSessionRestoreRequest/Result` 与 +当前 Runtime SDK 已提供会话创建、列出、删除、恢复、模型更新和类型化转录读取。模型更新只接受会话 ID 与模型 ID, +不承载模型目录、模式、提供方配置或宿主 UI 选择语义。`AgentSessionRestoreRequest/Result` 与 `AgentSessionRestorePort` 归 Agent Runtime SDK,以继续复用 Runtime owner 的完整 `SessionState`;类型化 `SessionTranscript` 归 `runtime-ports`。两者都由 `assembly/core` 注入真实持久化 owner,当前由 CLI/TUI 消费;ACP 为保证模型配置与完整历史来自同一次恢复,继续通过 Core 兼容门面读取协议回放所需的完整轮次,避免为单一协议 扩张通用 transcript。CLI/TUI 的工具确认、拒绝和用户问题回答,以及 ACP 服务端 / Peer Host 的工具确认与拒绝,通过类型化 `AgentInteractionResponsePort` 回到 Core 的工具管线或用户输入 owner,不改变审批策略或交互所有权。 `CoreAgentRuntimeCompatibility` 仍承载未迁移的 -模型更新、持久化、分支、用量和快照等操作;不能据此把整个兼容门面一次性删除,也不能把这些 +持久化、分支、用量和快照等操作;不能据此把整个兼容门面一次性删除,也不能把这些 操作提前声明为跨宿主稳定接口。旧固定 ID 创建方法仅作为标记废弃的源码迁移转发保留一个兼容周期,生产 CLI 调用已经迁移到 Runtime SDK;该转发不得重新成为新调用入口。 @@ -866,7 +868,8 @@ Provider 装配同样按需增加,不提前为 Memory、Context、Workflow、S CLI 托管的 ACP 服务端使用 `DeliveryProfile::Acp` 组装一个 Agent Runtime,通过 SDK 处理会话创建/列举、轮次提交/取消、 交互响应和只读 Agent 事件订阅。ACP 只把共享运行时事实映射成协议更新;标准输入输出、连接、权限 RPC 与通知生命周期 -不进入 SDK。完整持久化历史恢复、模型/模式、模型目录/配置、MCP 和 ACP 客户端路径仍是明确的 Core 兼容范围, +不进入 SDK。完整持久化历史恢复、模式、模型目录/提供方配置、MCP 和 ACP 客户端路径仍是明确的 Core 兼容范围; +活动会话的模型写入已经通过 SDK 回到 Core owner, 不据此扩张通用 runtime DTO。 继续拥有: @@ -1060,12 +1063,12 @@ Product 测试: - `bitfun-harness` 已提供类型化工作流描述与注册能力。 - `bitfun-core` 可继续作为 `product-full` 兼容门面,避免迁移期间一次性重写入口。 - CLI 已以 `DeliveryProfile::Cli` 构造真实 Runtime Parts 和 SDK runtime;本地 Agent 入口、会话、用量和 - Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过 SDK 提交/取消 - turn,通过单一 Core 兼容门面处理会话与快照缺口,不再构造独立调度器、持久化 manager 或事件队列; + Peer Host 共用一个调用级上下文与广播事件源,审批策略不再写回全局配置。Peer Host 通过 SDK 提交/精确取消 + turn、更新会话模型并处理工具确认/拒绝,通过单一 Core 兼容门面处理会话与快照缺口,不再构造独立调度器、持久化 manager 或事件队列; wire schema、Relay ACK/重放和重连协议未在该切换中扩张。 - CLI 通过 SDK 处理已覆盖的 session/turn/cancel 操作,并通过一个 Core 兼容门面处理 SDK v1 缺口; 该门面复用现有 owner,不建立第二套状态或事件 schema。 -- CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、工具确认/拒绝和 +- CLI 托管的 ACP 服务端已以 `DeliveryProfile::Acp` 构造真实 Runtime Parts;会话创建/列举、轮次、取消、会话模型更新、工具确认/拒绝和 Agent 事件订阅复用同一 SDK 语义,ACP stdio、连接与协议投影保持不变。Agentic Event Queue 仍是唯一事件 owner; 全局有界 broadcast 继续服务 CLI/TUI,活动 ACP prompt 使用固定容量、仅接收本会话事件的临时通道,并在最后一个订阅者 释放时立即回收。CLI 宿主进程只保留一个旧消费队列排空任务,不增加每会话转发任务或第二套事件 schema。ACP 组装入口使用独立的 @@ -1080,7 +1083,7 @@ Product 测试: - 把 embedded relay 的 TCP bind、静态 fallback 和任务生命周期移出 assembly;room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,Cargo 反向边已删除并由通用边界检查保护。 - 继续缩小 CLI 的 Core 兼容门面;只有稳定端口、真实生产调用方和行为等价测试齐备时才迁移 owner。 -- 继续按真实复用需求缩小 ACP 的完整持久化历史、模型/模式、MCP 与客户端兼容路径;Desktop 仅继续迁移存在稳定端口和 +- 继续按真实复用需求缩小 ACP 的完整持久化历史、模式、模型目录/提供方配置、MCP 与客户端兼容路径;Desktop 仅继续迁移存在稳定端口和 行为等价测试的入口。完整 Desktop 产品组装需先补齐真实必需服务与事件消费路径,不以桩实现提前声明能力;ACP 生命周期 和 Desktop 平台资源仍留在各自入口。 - 为 Agent Runtime SDK 增加至少一个非 `bitfun-core` 的真实嵌入方;预览 facade 和单元测试不等于外部可用 SDK。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index d24cd58c2a..d647538c03 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -95,8 +95,9 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 并把 `ProductRuntimeParts`、Agent Runtime SDK、事件源和调用级审批策略保存在一个 `CliRuntimeContext` 中。 - TUI、`exec`、会话、用量和交互模式下的 Peer Host 复用同一上下文。SDK 已承接会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除、轮次提交和取消;普通创建 - DTO 保持 v1 字段集合,固定 ID 冲突返回 `InvalidRequest`。SDK v1 尚未覆盖的恢复视图、 - 消息、分支、用量、快照和工具确认由一个 Core + DTO 保持 v1 字段集合,固定 ID 冲突返回 `InvalidRequest`。会话模型更新、工具确认/拒绝和用户问题回答也通过 + SDK 的窄端口回到 Core owner;模型目录、模式和提供方配置仍由产品入口解释。SDK v1 尚未覆盖的恢复视图、 + 消息、分支、用量和快照由一个 Core 兼容门面转发给原 owner。 - Agentic Event Queue 仍是唯一事件 owner;TUI、`exec` 与 Peer Host 使用独立广播订阅,不互相消费事件。 - 有界旧队列只承担兼容存储;达到容量时不得抑制广播。CLI 保持一个后台 drain,订阅方一旦报告 lag/closed, @@ -107,8 +108,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 相对路径、绝对路径或分隔符越出 sessions 根目录。 - TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 - 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;resize 渲染正确性、Chat 活动 turn、初始化失败与异常退出等 - 仍需独立验收。 + 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;Chat 活动 turn 的 resize 静默期已有状态单测,窄屏流式 + reflow 已有 TestBackend 回归,真实 PTY 活动 turn、初始化失败与异常退出等仍需独立验收。 - Startup 与 Chat 共用 CLI 私有输入读取器;一次读取同时受 256 个事件和 50ms 限制,跨批次仅延续快速文本尾部, 短批次普通按键保持原有路由。被识别为粘贴的文本按批次写入输入缓冲,每批只刷新一次命令菜单;粘贴内容中的 Tab 明确转换为四个空格。 @@ -146,7 +147,7 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 第一条闭环只完成一个无外部依赖的契约样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;Linux 与 Windows 分别运行启动页 PTY/ConPTY 生命周期冒烟,发布归档上传前完成 SHA-256 与解压执行验证 | 真实模型审批/取消、Chat 活动 turn、resize 渲染、Patch I/O 失败和终端故障注入仍可能晚于 PR 发现 | 继续补剩余进程级故障契约;避免为同一依赖图重复建立三平台编译矩阵。 | +| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;Linux 与 Windows 分别运行启动页 PTY/ConPTY 生命周期冒烟,发布归档上传前完成 SHA-256 与解压执行验证 | 真实模型审批/取消、真实 PTY 活动 turn 的 resize、Patch I/O 失败和终端故障注入仍可能晚于 PR 发现 | 继续补剩余进程级故障契约;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -157,8 +158,8 @@ CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现 CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、Runtime Parts、调用级审批、共享事件源和 本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费和 TUI 进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 SDK runtime;启动页 -PTY/ConPTY 生命周期冒烟测试与发布归档冒烟测试已存在,真实模型、Chat 活动 turn、resize 渲染、终端故障注入与 -权限失败等完整进程级验收仍需另行完成。 +PTY/ConPTY 生命周期冒烟测试与发布归档冒烟测试已存在;Chat 活动 turn 的 resize 静默期已有确定性状态单测, +窄屏流式 reflow 已有 TestBackend 回归,真实模型、真实 PTY 活动 turn、终端故障注入与权限失败等完整进程级验收仍需另行完成。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: @@ -267,8 +268,9 @@ TUI renderer、实验性接口和完整外部 Server 协议按总矩阵明确降 | 生态配置适配器 | 解析受支持外部格式并生成导入候选/诊断 | 直接写运行时配置、读取密钥、决定最终权限 | CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Runtime SDK 的类型化端口完成;TUI 只把 -`SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host、账户同步、会话分支、用量、快照和 -工具交互仍使用经过审查的 Core compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 +`SessionTranscript` 投影为本地渲染状态,不再消费 Core `Message`。Peer Host 的对话提交、精确取消、会话模型更新和 +工具确认/拒绝通过 SDK 回到 Core owner;账户同步、会话分支、用量、快照及其他未覆盖操作仍使用经过审查的 Core +compatibility 方法,直到各自具备明确 owner、稳定 DTO、远程语义和行为等价测试。 这是一条垂直链路迁移,不是删除整个兼容门面或新建 CLI 专用服务层。 Runtime Configuration Service 当前由 `bitfun-core/service/config` 负责。在经评审的 port/provider @@ -339,8 +341,10 @@ flowchart LR | Action Registry | 统一 action id、slash/palette/help、上下文、可用性、处理器和默认键位;不持有业务状态 | | Keymap Resolver | 根据当前模式和焦点把用户显式配置或默认键位解析为 action id;不直接执行业务副作用 | -`modes/chat.rs` 最终只保留生命周期编排;现有 `ui/chat/state.rs`、`input.rs`、`render.rs` 等模块继续作为 -收敛基础。拆分以可测试边界为目的,不以文件数量为目标。 +`modes/chat.rs` 当前主要保留 `ChatMode` 外壳、共享上下文和私有子文件组织;生命周期、输入/命令、选择器、MCP、 +会话与能力副作用按职责保留在同一 Rust 模块的 `modes/chat/` 子文件中。现有 `ui/chat/state.rs`、`input.rs`、 +`render.rs` 等模块继续作为收敛基础。该拆分不形成公共 TUI 框架,也不改变交互规格;后续仍以可测试边界为目的, +不以文件数量为目标。 ### 4.5 Action 与快捷键 @@ -598,7 +602,7 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes 通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 `cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI 测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;发布归档在上传前完成 SHA-256 与解压执行 -验证。完整模型、Chat 活动 turn、resize 渲染与故障进程矩阵仍按对应切片补入门禁,不能由序列化单测或基础冒烟 +验证。真实模型进程级交互、真实 PTY 中的 Chat 活动 turn resize 与故障进程矩阵仍按对应切片补入门禁,不能由序列化单测或基础冒烟 测试代替。 ### 10.2 阶段退出条件 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 3e73e38da8..fd08ce6b9f 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -326,7 +326,7 @@ flowchart LR | 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 本地能力 | -| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;会话恢复、模型/模式、MCP、客户端与协议生命周期仍留在现有 Core/ACP 归属,不据此宣称完整解耦 | +| ACP | CLI 托管的服务端仍以 `bitfun-core/product-full` 作为兼容执行层 | 入口已选择 `DeliveryProfile::Acp` 并消费 Runtime Parts;组装层在入队前原子拒绝忙碌会话,不改变其他产品入口的排队行为;活动会话模型写入走 SDK,会话恢复、模式、模型目录/提供方配置、MCP、客户端与协议生命周期仍留在现有 Core/ACP 归属,不据此宣称完整解耦 | | Server / Remote | 当前生产路由没有插件状态消费闭环;Remote 插件执行未实现 | 不在本地替远端项目发现、准备或执行插件;未接入时返回明确不支持 | | Web / Mobile Web | 依赖现有后端入口,不持有插件执行单元 | 对应 profile 当前为空计划或未接入生产,不能据枚举值宣称独立产品能力 | | SDK | 仅有 preview 门面、空 profile 计划和测试替身 | 不牵引 `product-full`、具体服务管理器或插件 host ABI;未满足独立嵌入验证前不宣称可发布 | diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index afd8efbf86..d048b4de12 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -28,7 +28,7 @@ | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded 宿主逻辑仍在 assembly 兼容路径 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位是独立后续工作 | -| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期进程冒烟,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;真实模型、Chat 活动 turn、resize 渲染、终端故障注入与 Patch I/O 失败仍需补齐 | +| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期进程冒烟,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;resize 静默期已有状态单测,活动流式内容窄屏 reflow 已有 TestBackend 回归,真实模型/PTY 活动 turn、终端故障注入与 Patch I/O 失败仍需补齐 | ## 3. 目标依赖与归属 @@ -61,7 +61,7 @@ CLI 是首个入口迁移对象,因为它已有独立产品诉求、显式设 当前纵向切片已经完成:入口只提交一次 `DeliveryProfile::Cli`,通过现有 `ProductAssembler` 获得计划、服务可用性、 Harness 和禁用的插件 binding;TUI、Exec、Session 与 Usage 共用一个 `CliRuntimeContext`。会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/ -删除、轮次提交和取消走 Agent Runtime SDK;SDK v1 缺口集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 +删除、会话模型更新、轮次提交和取消走 Agent Runtime SDK;SDK v1 缺口集中在一个 Core 兼容门面。Agentic Event Queue 仍是唯一 owner,各入口只建立独立广播订阅,有界兼容队列满载不再阻断广播。TUI 与 Exec 审批均为调用级策略,不写全局 配置;CLI 本地路径不获取具体 PersistenceManager。交互、执行和管理入口分别控制 Peer Host/MCP 生命周期,管理查询不启动 这两类外部服务。结构化输出复用现有 Agentic envelope;会话 ID 与 @@ -72,13 +72,13 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立 下一步按独立纵向切片推进: -1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小模型更新、分支、用量、快照和持久化维护兼容面。 -2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 +1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小分支、用量、快照和持久化维护兼容面;会话模型更新已迁移,模型目录与配置仍保留在产品入口。 +2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型目录/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 3. 继续按真实故障样例拆分 TUI 副作用边界,不以大规模重写替代现有回归保护。 当前 assembly 切换条件已经满足:CLI 生产入口消费真实组装结果,目标链路没有第二套状态,独立测试与三平台 编译门禁存在,启动页 PTY/ConPTY 生命周期与发布归档冒烟测试已接入门禁。CLI-P0 整体退出条件尚未满足; -真实模型交互、Chat 活动 turn、resize 渲染、终端故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 +真实模型交互、真实 PTY 活动 turn 的 resize、终端故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 ### 4.3 依次切换 ACP 与 Desktop diff --git a/docs/plans/product-architecture-evolution-plan.md b/docs/plans/product-architecture-evolution-plan.md index ab1d9e57e3..68b8698407 100644 --- a/docs/plans/product-architecture-evolution-plan.md +++ b/docs/plans/product-architecture-evolution-plan.md @@ -99,14 +99,14 @@ HarmonyOS 手机 Remote App 不在该平台执行范围内。 ## 7. 工作流五:入口逐项迁移 -- CLI:会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录和轮次 - 提交/取消已由真实入口消费 Runtime SDK; - TUI 通过 SDK 处理工具确认、拒绝和用户问题回答,Exec 与交互模式下的 Peer Host 处理工具确认与拒绝。模型 - 更新、分支、用量、快照和持久化维护等操作在补齐端口与行为测试前继续由 - 现有单一兼容路径转发。 -- ACP:CLI 行为稳定后单独迁移会话、权限和事件投影;ACP stdio 生命周期留在接口入口。 -- Desktop:主界面轮次提交/取消、工具确认/拒绝和用户问题回答已通过现有协调器与调度器端口构造的窄口径 SDK 门面; - 完整产品组装需等待真实必需服务与事件消费路径。会话 CRUD/恢复视图、模型、MCP、MiniApp、Cron、远程连接、 +- CLI:会话创建(包括 `exec --session-id` 和缺失后端会话通过独立固定 ID 方法按原 ID 重建)/列举/删除/恢复、类型化转录、轮次 + 提交/取消、活动会话模型更新和 TUI 工具确认/拒绝/用户问题回答已由真实入口消费 Runtime SDK;交互模式下的 Peer Host + 也通过同一 SDK 提交/精确取消 turn、更新活动会话模型并处理工具确认/拒绝。分支、用量、快照和持久化维护等未覆盖操作 + 继续由现有单一兼容路径转发。 +- ACP:CLI 托管服务端的会话创建/列举、轮次提交/取消、活动会话模型更新、工具确认/拒绝、用户问题回答和事件订阅 + 已复用同一 SDK 语义;ACP stdio、连接、权限 RPC 与通知生命周期仍留在接口入口。 +- Desktop:主界面轮次提交/取消、活动会话模型更新、工具确认/拒绝和用户问题回答已通过现有协调器与调度器端口构造的窄口径 SDK 门面; + 完整产品组装需等待真实必需服务与事件消费路径。会话 CRUD/恢复视图、模型目录/提供方配置、MCP、MiniApp、Cron、远程连接、 Tauri 窗口和 app-local 资源仍留在原入口。 - SDK/Server/Remote:只有真实独立调用方出现后才增加;枚举、空计划或测试替身不构成发布能力。 diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index f471ed7ccb..740353c562 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -11,9 +11,9 @@ use tokio::sync::Mutex; use super::Agent; use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, - AgentSessionListRequest, AgentSessionRestoreRequest, AgentToolConfirmationRequest, - AgentToolRejectionRequest, AgentTurnCancellationRequest, AgentUserAnswersRequest, - SessionTranscript, SessionTranscriptRequest, + AgentSessionListRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, + AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest, + AgentUserAnswersRequest, SessionTranscript, SessionTranscriptRequest, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_core::agentic::persistence::session_branch::SessionBranchResult; @@ -173,10 +173,13 @@ impl CoreAgentAdapter { session_id: &str, model_id: &str, ) -> Result<()> { - self.compatibility - .update_session_model(session_id, model_id) + self.runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: session_id.to_string(), + model_id: model_id.to_string(), + }) .await - .map_err(Into::into) + .map_err(|error| anyhow::anyhow!(error.into_message())) } pub(crate) async fn branch_session_at_latest_turn( @@ -540,6 +543,17 @@ mod tests { use super::validated_session_summary; + #[test] + fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { + let source = include_str!("core_adapter.rs"); + let runtime_update = ["runtime", "\n .update_session_model"].concat(); + let compatibility_update = + ["compatibility", "\n .update_session_model"].concat(); + + assert!(source.contains(&runtime_update)); + assert!(!source.contains(&compatibility_update)); + } + fn session_summary(session_id: &str) -> AgentSessionSummary { AgentSessionSummary { session_id: session_id.to_string(), diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index eae0e7392c..806fc81c2e 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -2,6 +2,8 @@ /// /// Interactive chat mode with TUI interface. /// Events are observed through an independent runtime broadcast subscription. +mod resize; + use anyhow::{anyhow, Result}; use arboard::Clipboard; use crossterm::event::{ @@ -17,6 +19,7 @@ use std::time::{Duration, Instant}; use tokio::sync::broadcast::error::TryRecvError; use bitfun_events::{AgenticEvent, ToolEventData}; +use resize::ResizeRedrawState; use crate::actions::{ action_by_id, action_for_alias, slash_actions, ActionContext, ActionHandler, ActionSpec, @@ -77,897 +80,8 @@ const SPINNER_REDRAW_INTERVAL_MS: u64 = 100; /// Coalesce rapid resize bursts to reduce flicker during window drag. const RESIZE_REDRAW_DEBOUNCE_MS: u64 = 75; -fn native_command_conflict_key<'a>( - execution_domain_id: &str, - command_name: &str, - candidates: impl IntoIterator, -) -> String { - format!( - "native:{}", - prompt_command_conflict_key(execution_domain_id, command_name, candidates) - ) -} - -fn external_command_projections( - snapshot: &ExternalSourceCatalogSnapshot, - conflict_choices: &BTreeMap, -) -> Vec { - let built_in_actions = slash_actions(ActionState::chat(false, false)); - let mut projections = snapshot - .commands - .iter() - .map(|entry| { - let ecosystem = snapshot - .sources - .iter() - .find(|source| source.record.key == entry.definition.id.source) - .map(|source| source.record.ecosystem_id.as_str()) - .unwrap_or("external"); - let restricted = !matches!( - entry.definition.availability, - PromptCommandAvailability::Available - ); - let native_collision = built_in_actions.iter().find_map(|action| { - if !action - .name - .trim_start_matches('/') - .eq_ignore_ascii_case(&entry.definition.name) - { - return None; - } - let source = snapshot - .sources - .iter() - .find(|source| source.record.key == entry.definition.id.source)?; - let native_candidate_id = format!("bitfun.cli:{}", action.id); - let external_candidate_id = entry.definition.id.stable_key(); - let conflict_key = native_command_conflict_key( - source.record.execution_domain_id.as_str(), - &entry.definition.name, - [ - (native_candidate_id.as_str(), env!("CARGO_PKG_VERSION")), - ( - external_candidate_id.as_str(), - entry.definition.content_version.as_str(), - ), - ], - ); - Some(NativeCommandCollisionProjection { - native_action_id: action.id.to_string(), - native_candidate_id, - external_candidate_id, - selected_candidate_id: conflict_choices.get(&conflict_key).cloned(), - conflict_key, - }) - }); - ExternalCommandProjection { - action_id: format!("external-command:{}", entry.definition.name), - command_name: entry.definition.name.clone(), - invocation_alias: format!("/{}", entry.definition.name), - candidate_id: entry.definition.id.stable_key(), - content_version: entry.definition.content_version.clone(), - description: format!("{} · {}", entry.definition.description, ecosystem), - restricted, - provider_conflict_key: None, - native_collision, - } - }) - .collect::>(); - - for conflict in snapshot - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - { - let built_in = built_in_actions.iter().find(|action| { - action - .name - .trim_start_matches('/') - .eq_ignore_ascii_case(&conflict.command_name) - }); - let native_group = built_in.and_then(|action| { - let execution_domain = conflict.candidates.iter().find_map(|candidate| { - snapshot - .sources - .iter() - .find(|source| source.record.key == candidate.source) - .map(|source| source.record.execution_domain_id.as_str()) - })?; - let native_candidate_id = format!("bitfun.cli:{}", action.id); - let mut candidates = conflict - .candidates - .iter() - .map(|candidate| { - ( - candidate.candidate_id.as_str(), - candidate.content_version.as_str(), - ) - }) - .collect::>(); - candidates.push((native_candidate_id.as_str(), env!("CARGO_PKG_VERSION"))); - let conflict_key = - native_command_conflict_key(execution_domain, &conflict.command_name, candidates); - Some((action.id.to_string(), native_candidate_id, conflict_key)) - }); - projections.extend(conflict.candidates.iter().map(|candidate| { - let native_collision = native_group.as_ref().map( - |(native_action_id, native_candidate_id, conflict_key)| { - NativeCommandCollisionProjection { - native_action_id: native_action_id.clone(), - native_candidate_id: native_candidate_id.clone(), - external_candidate_id: candidate.candidate_id.clone(), - selected_candidate_id: conflict_choices.get(conflict_key).cloned(), - conflict_key: conflict_key.clone(), - } - }, - ); - ExternalCommandProjection { - action_id: format!("external-command-candidate:{}", candidate.candidate_id), - command_name: conflict.command_name.clone(), - invocation_alias: format!( - "/external:{}:{}", - candidate.source.provider_id, conflict.command_name - ), - candidate_id: candidate.candidate_id.clone(), - content_version: candidate.content_version.clone(), - description: format!( - "{} · {} · {}", - candidate.command_description, - candidate.source_display_name, - candidate.ecosystem_id - ), - restricted: !matches!(candidate.availability, PromptCommandAvailability::Available), - provider_conflict_key: Some(conflict.conflict_key.clone()), - native_collision, - } - })); - } - projections -} - -fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, usize) { - snapshot - .commands - .iter() - .fold((0, 0), |(available, restricted), entry| { - if matches!( - entry.definition.availability, - PromptCommandAvailability::Available - ) { - (available + 1, restricted) - } else { - (available, restricted + 1) - } - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ExternalToolReviewAction { - Show, - Refresh, - Decide { - approval_key: String, - decision_key: String, - approved: bool, - }, - Choose { - conflict_key: String, - candidate_id: String, - }, -} - -struct ExternalToolMutationResult { - action: ExternalToolReviewAction, - result: std::result::Result, -} - -struct ExternalToolTargetSummary<'a> { - tools: Vec<&'a ExternalToolCatalogEntry>, -} - -impl<'a> ExternalToolTargetSummary<'a> { - fn first(&self) -> &'a ExternalToolCatalogEntry { - self.tools[0] - } - - fn activation(&self) -> &'a ExternalToolActivationState { - &self.first().activation - } - - fn names(&self) -> String { - let mut names = self - .tools - .iter() - .map(|tool| tool.definition.name.as_str()) - .collect::>(); - names.sort_unstable(); - names.dedup(); - names.join(", ") - } -} - -fn external_tool_target_summaries( - snapshot: &ExternalSourceCatalogSnapshot, -) -> Vec> { - let mut summaries: Vec> = Vec::new(); - for tool in &snapshot.tools { - if let Some(summary) = summaries - .iter_mut() - .find(|summary| summary.first().definition.id.target == tool.definition.id.target) - { - summary.tools.push(tool); - } else { - summaries.push(ExternalToolTargetSummary { tools: vec![tool] }); - } - } - summaries -} - -fn external_tool_activation_label(activation: &ExternalToolActivationState) -> &'static str { - match activation { - ExternalToolActivationState::ApprovalRequired => "approval required", - ExternalToolActivationState::Disabled => "disabled", - ExternalToolActivationState::Active => "active", - ExternalToolActivationState::Conflict => "name conflict", - ExternalToolActivationState::Unsupported { .. } => "unsupported", - ExternalToolActivationState::RuntimeUnavailable { .. } => "runtime unavailable", - ExternalToolActivationState::LoadFailed { .. } => "load failed", - _ => "unknown", - } -} - -fn external_tool_scope_label(scope: impl std::fmt::Debug) -> &'static str { - match format!("{scope:?}").as_str() { - "UserGlobal" => "user global", - "Project" => "project", - "WorkspaceLocal" => "workspace local", - "RemoteUser" => "remote user", - "RemoteProject" => "remote project", - _ => "unknown", - } -} - -fn external_tool_user_facing_reason(reason: &str) -> String { - reason - .replace("PR2 worker", "Tool process") - .replace("PR2", "This version") -} - -fn external_tool_reason(summary: &ExternalToolTargetSummary<'_>) -> Option { - match summary.activation() { - ExternalToolActivationState::Unsupported { reason } - | ExternalToolActivationState::RuntimeUnavailable { reason } - | ExternalToolActivationState::LoadFailed { reason } => { - Some(external_tool_user_facing_reason(reason)) - } - _ => None, - } -} - -fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static str { - match activation { - ExternalToolActivationState::ApprovalRequired => { - "Review access, then enable the target or keep it disabled." - } - ExternalToolActivationState::Disabled => { - "Enable the target after reviewing its source and access." - } - ExternalToolActivationState::Active => { - "No action is required. Disable the target to stop exposing it." - } - ExternalToolActivationState::Conflict => "Choose a provider below, or disable this target.", - ExternalToolActivationState::Unsupported { .. } => { - "Convert the module to the supported standalone JavaScript subset, then refresh." - } - ExternalToolActivationState::RuntimeUnavailable { .. } => { - "Restore the required runtime, then refresh." - } - ExternalToolActivationState::LoadFailed { .. } => { - "Refresh to retry. If it still fails, inspect or update the module, or disable this target." - } - _ => "Refresh to retrieve the current target state.", - } -} - -fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'static str { - match activation { - ExternalToolActivationState::ApprovalRequired => { - "The module needs approval before BitFun loads it." - } - ExternalToolActivationState::Disabled => "The target was disabled by user choice.", - ExternalToolActivationState::Active => { - "The module loaded successfully and is exposed in this execution domain." - } - ExternalToolActivationState::Conflict => "Another implementation uses the same tool name.", - ExternalToolActivationState::Unsupported { .. } => { - "The module uses unsupported syntax or behavior." - } - ExternalToolActivationState::RuntimeUnavailable { .. } => { - "The required JavaScript runtime is unavailable." - } - ExternalToolActivationState::LoadFailed { .. } => { - "The tool process could not load this module." - } - _ => "The current state is unavailable.", - } -} - -fn external_tool_can_enable(activation: &ExternalToolActivationState) -> bool { - matches!( - activation, - ExternalToolActivationState::ApprovalRequired | ExternalToolActivationState::Disabled - ) -} - -fn external_tool_can_disable(activation: &ExternalToolActivationState) -> bool { - matches!( - activation, - ExternalToolActivationState::ApprovalRequired - | ExternalToolActivationState::Active - | ExternalToolActivationState::Conflict - | ExternalToolActivationState::LoadFailed { .. } - ) -} - -fn external_tool_result_is_stale( - current: Option<&ExternalSourceCatalogSnapshot>, - incoming: &ExternalSourceCatalogSnapshot, -) -> bool { - current.is_some_and(|current| current.generation > incoming.generation) -} - -fn external_tool_pending_notice_key(snapshot: &ExternalSourceCatalogSnapshot) -> Option { - let mut decisions = snapshot - .tool_approval_requests - .iter() - .map(|request| format!("approval:{}", request.decision_key)) - .chain( - snapshot - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .map(|conflict| format!("conflict:{}", conflict.conflict_key)), - ) - .collect::>(); - decisions.extend(snapshot.diagnostics.iter().filter_map(|diagnostic| { - matches!( - diagnostic.severity, - ExternalSourceDiagnosticSeverity::Warning | ExternalSourceDiagnosticSeverity::Error - ) - .then(|| { - format!( - "diagnostic:{:?}:{}:{}:{}", - diagnostic.severity, - diagnostic.code, - diagnostic.message, - diagnostic - .source - .as_ref() - .map(|source| source.stable_key()) - .unwrap_or_default() - ) - }) - })); - if decisions.is_empty() { - return None; - } - decisions.sort_unstable(); - Some(decisions.join("\n")) -} - -fn external_tool_capability_label(capability: ExternalToolCapability) -> &'static str { - match capability { - ExternalToolCapability::FileSystem => "filesystem", - ExternalToolCapability::Network => "network", - ExternalToolCapability::Process => "process", - ExternalToolCapability::Environment => "environment variables", - _ => "other", - } -} - -fn external_tool_runtime_label(runtime: ExternalToolRuntimeKind) -> &'static str { - match runtime { - ExternalToolRuntimeKind::JavaScript => "JavaScript", - ExternalToolRuntimeKind::TypeScript => "TypeScript", - _ => "unknown runtime", - } -} - -fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { - let Some(snapshot) = snapshot else { - return "External tools\n\nTool discovery has not completed. Run /external-tools refresh and try again." - .to_string(); - }; - let mut lines = vec![ - "External tools".to_string(), - String::new(), - "No external code ran during discovery. Enabling a tool starts its external module with your user permissions and inherited environment variables; BitFun does not provide an OS sandbox or full descendant-process cleanup in this version." - .to_string(), - ]; - - if snapshot.discovery_pending { - lines.push(String::new()); - lines.push("Discovery is still running. Existing results remain usable.".to_string()); - } - - lines.push(String::new()); - lines.push("Targets".to_string()); - let targets = external_tool_target_summaries(snapshot); - if targets.is_empty() { - lines.push(" None".to_string()); - } else { - for (index, target) in targets.iter().enumerate() { - let tool = target.first(); - let source = snapshot - .sources - .iter() - .find(|source| source.record.key == tool.definition.id.target.source); - let capabilities = target - .tools - .iter() - .flat_map(|tool| tool.definition.capabilities.iter().copied()) - .collect::>() - .into_iter() - .map(external_tool_capability_label) - .collect::>() - .join(", "); - lines.push(format!( - " {}. {} - {}", - index + 1, - target.names(), - external_tool_activation_label(target.activation()) - )); - lines.push(format!( - " Source root: {}", - source - .map(|source| source.record.location.as_str()) - .unwrap_or("unknown") - )); - lines.push(" Module files:".to_string()); - let module_paths = target - .tools - .iter() - .map(|tool| tool.definition.module_path.as_str()) - .collect::>(); - for module_path in module_paths { - lines.push(format!(" - {module_path}")); - } - lines.push(format!( - " Scope: {}", - source - .map(|source| external_tool_scope_label(source.record.scope)) - .unwrap_or("unknown") - )); - lines.push(format!( - " Execution domain: {}", - source - .map(|source| source.record.execution_domain_id.as_str()) - .unwrap_or("unknown") - )); - lines.push(format!( - " Working directory: {}", - tool.definition.working_directory - )); - lines.push(format!( - " Runtime: {}", - external_tool_runtime_label(tool.definition.runtime_kind) - )); - lines.push(format!(" Access: {capabilities}")); - if let Some(reason) = external_tool_reason(target) { - lines.push(format!(" Reason: {reason}")); - } else { - lines.push(format!( - " Reason: {}", - external_tool_default_reason(target.activation()) - )); - } - lines.push(format!( - " Next step: {}", - external_tool_next_step(target.activation()) - )); - let mut commands = Vec::new(); - if external_tool_can_enable(target.activation()) { - commands.push(format!("/external-tools enable {}", index + 1)); - } - if external_tool_can_disable(target.activation()) { - commands.push(format!("/external-tools disable {}", index + 1)); - } - if !commands.is_empty() { - lines.push(format!(" Commands: {}", commands.join(" or "))); - } - } - } - - lines.push(String::new()); - lines.push("Name conflicts".to_string()); - let pending_conflicts = snapshot - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .collect::>(); - if pending_conflicts.is_empty() { - lines.push(" None".to_string()); - } else { - for (conflict_index, conflict) in pending_conflicts.iter().enumerate() { - lines.push(format!( - " {}. Tool '{}' requires a provider choice:", - conflict_index + 1, - conflict.tool_name - )); - for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { - lines.push(format!( - " {}. {} ({}) - /external-tools choose {} {}", - candidate_index + 1, - candidate.display_name, - candidate.provider_id, - conflict_index + 1, - candidate_index + 1 - )); - } - lines.push( - " The choice applies to matching candidates in this execution domain." - .to_string(), - ); - } - } - - lines.push(String::new()); - lines.push("Diagnostics".to_string()); - if snapshot.diagnostics.is_empty() { - lines.push(" None".to_string()); - } else { - for diagnostic in &snapshot.diagnostics { - let severity = match diagnostic.severity { - ExternalSourceDiagnosticSeverity::Info => "info", - ExternalSourceDiagnosticSeverity::Warning => "warning", - ExternalSourceDiagnosticSeverity::Error => "error", - _ => "notice", - }; - let source = diagnostic - .source - .as_ref() - .map(|source| format!(" [{}]", source.stable_key())) - .unwrap_or_default(); - lines.push(format!( - " - {severity} [{}]{source}: {}", - diagnostic.code, - external_tool_user_facing_reason(&diagnostic.message) - )); - } - } - - lines.push(String::new()); - lines.push( - "Use /external-tools refresh after editing, upgrading, or removing external tools." - .to_string(), - ); - lines.join("\n") -} - -fn parse_positive_index(value: Option<&str>, label: &str) -> Result { - let raw = value.ok_or_else(|| format!("missing {label}"))?; - let index = raw - .parse::() - .map_err(|_| format!("{label} must be a positive number"))?; - if index == 0 { - return Err(format!("{label} must be a positive number")); - } - Ok(index - 1) -} - -fn parse_external_tool_review_action( - arguments: &str, - current_snapshot: Option<&ExternalSourceCatalogSnapshot>, - reviewed_snapshot: Option<&ExternalSourceCatalogSnapshot>, -) -> Result { - let mut parts = arguments.split_whitespace(); - let Some(command) = parts.next() else { - return Ok(ExternalToolReviewAction::Show); - }; - if command.eq_ignore_ascii_case("refresh") { - if parts.next().is_some() { - return Err("usage: /external-tools refresh".to_string()); - } - return Ok(ExternalToolReviewAction::Refresh); - } - if command.eq_ignore_ascii_case("help") { - return Ok(ExternalToolReviewAction::Show); - } - // Numbered commands refer to the immutable catalog that produced the - // review popup. The backend still validates stable decision/conflict keys, - // 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(|| { - "tool discovery has not completed; run /external-tools refresh".to_string() - })?; - if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { - let index = parse_positive_index(parts.next(), "target number")?; - if parts.next().is_some() { - return Err(format!("usage: /external-tools {command} ")); - } - let targets = external_tool_target_summaries(snapshot); - let target = targets.get(index).ok_or_else(|| { - "that target is no longer available; reopen /external-tools".to_string() - })?; - let approved = command.eq_ignore_ascii_case("enable"); - let allowed = if approved { - external_tool_can_enable(target.activation()) - } else { - external_tool_can_disable(target.activation()) - }; - if !allowed { - return Err(format!( - "target {} is {}; reopen /external-tools for its next step", - index + 1, - external_tool_activation_label(target.activation()) - )); - } - let tool = target.first(); - return Ok(ExternalToolReviewAction::Decide { - approval_key: tool.approval_key.clone(), - decision_key: tool.decision_key.clone(), - approved, - }); - } - if command.eq_ignore_ascii_case("choose") { - let conflict_index = parse_positive_index(parts.next(), "conflict number")?; - let candidate_index = parse_positive_index(parts.next(), "candidate number")?; - if parts.next().is_some() { - return Err( - "usage: /external-tools choose ".to_string(), - ); - } - let conflict = snapshot - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .nth(conflict_index) - .ok_or_else(|| { - "that conflict is no longer available; reopen /external-tools".to_string() - })?; - let candidate = conflict.candidates.get(candidate_index).ok_or_else(|| { - "that candidate is no longer available; reopen /external-tools".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()) -} - -fn external_tool_mutation_result_label( - action: &ExternalToolReviewAction, - snapshot: &ExternalSourceCatalogSnapshot, -) -> String { - match action { - ExternalToolReviewAction::Refresh => "External tools refreshed".to_string(), - ExternalToolReviewAction::Decide { - approval_key, - decision_key, - approved: true, - } => { - let activations = snapshot - .tools - .iter() - .filter(|tool| { - tool.approval_key == *approval_key && tool.decision_key == *decision_key - }) - .map(|tool| &tool.activation) - .collect::>(); - if activations.is_empty() { - "External tool approval saved; reopen /external-tools to review the changed target" - .to_string() - } else if activations - .iter() - .any(|state| matches!(state, ExternalToolActivationState::LoadFailed { .. })) - { - "External tool approved, but loading failed".to_string() - } else if activations.iter().any(|state| { - matches!( - state, - ExternalToolActivationState::RuntimeUnavailable { .. } - ) - }) { - "External tool approved, but its runtime is unavailable".to_string() - } else if activations - .iter() - .any(|state| matches!(state, ExternalToolActivationState::Conflict)) - { - "External tool approved; choose a provider before every export is available" - .to_string() - } else if activations - .iter() - .all(|state| matches!(state, ExternalToolActivationState::Active)) - { - "External tool enabled".to_string() - } else { - "External tool approval saved; reopen /external-tools to review its current state" - .to_string() - } - } - ExternalToolReviewAction::Decide { - approval_key, - decision_key, - approved: false, - } => { - let disabled = snapshot.tools.iter().any(|tool| { - tool.approval_key == *approval_key - && tool.decision_key == *decision_key - && matches!(tool.activation, ExternalToolActivationState::Disabled) - }); - if disabled { - "External tool disabled".to_string() - } else { - "External tool decision saved; reopen /external-tools to review the changed target" - .to_string() - } - } - ExternalToolReviewAction::Choose { - conflict_key, - candidate_id, - } => { - let selected = snapshot.tool_conflicts.iter().any(|conflict| { - conflict.conflict_key == *conflict_key - && conflict.selected_candidate_id.as_deref() == Some(candidate_id.as_str()) - }); - if selected { - "External tool provider selected".to_string() - } else { - "External tool candidates changed; reopen /external-tools before choosing" - .to_string() - } - } - ExternalToolReviewAction::Show => "External tools".to_string(), - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BuiltinCommandReconfirmation { - conflict_key: String, - candidate_id: String, - confirmed: bool, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct ExternalSourceConflictPreferences { - choices: BTreeMap, - lineage_current_keys: BTreeMap, - conflicted_candidate_ids: BTreeSet, -} - -impl - From<( - BTreeMap, - BTreeMap, - BTreeSet, - )> for ExternalSourceConflictPreferences -{ - fn from( - (choices, lineage_current_keys, conflicted_candidate_ids): ( - BTreeMap, - BTreeMap, - BTreeSet, - ), - ) -> Self { - Self { - choices, - lineage_current_keys, - conflicted_candidate_ids, - } - } -} - -fn builtin_command_reconfirmation( - action_id: &str, - action_name: &str, - preferences: &ExternalSourceConflictPreferences, -) -> Option { - let candidate_id = format!("bitfun.cli:{action_id}"); - let participated_in_conflict = preferences.conflicted_candidate_ids.contains(&candidate_id); - if !participated_in_conflict { - return None; - } - let command_name = action_name.trim_start_matches('/'); - let conflict_key = native_command_conflict_key( - "local-user", - command_name, - [(candidate_id.as_str(), env!("CARGO_PKG_VERSION"))], - ); - let confirmed = preferences.choices.get(&conflict_key) == Some(&candidate_id); - Some(BuiltinCommandReconfirmation { - conflict_key, - candidate_id, - confirmed, - }) -} - -fn builtin_reconfirmation_names( - preferences: &ExternalSourceConflictPreferences, -) -> BTreeSet { - slash_actions(ActionState::chat(false, false)) - .into_iter() - .filter(|action| { - builtin_command_reconfirmation(action.id, action.name, preferences) - .is_some_and(|reconfirmation| !reconfirmation.confirmed) - }) - .map(|action| action.name.trim_start_matches('/').to_ascii_lowercase()) - .collect() -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandQualifier { - Unqualified, - Builtin, - External, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandRoute { - Builtin, - External, - AskForCollisionChoice, - WaitForDiscovery, - UnknownBuiltin, -} - -fn parse_command_token(token: &str) -> (CommandQualifier, &str) { - let requested_name = token.trim_start_matches('/'); - let Some((qualifier, command_name)) = requested_name.split_once(':') else { - return (CommandQualifier::Unqualified, requested_name); - }; - if qualifier.eq_ignore_ascii_case("builtin") { - (CommandQualifier::Builtin, command_name) - } else if qualifier.eq_ignore_ascii_case("external") { - (CommandQualifier::External, command_name) - } else { - (CommandQualifier::Unqualified, requested_name) - } -} - -fn command_route( - qualifier: CommandQualifier, - has_builtin: bool, - external: Option<&ExternalCommandProjection>, - discovery_pending: bool, - builtin_reconfirmation_required: bool, -) -> CommandRoute { - match qualifier { - CommandQualifier::Builtin => { - if has_builtin { - CommandRoute::Builtin - } else { - CommandRoute::UnknownBuiltin - } - } - CommandQualifier::External => CommandRoute::External, - CommandQualifier::Unqualified => { - if builtin_reconfirmation_required { - return CommandRoute::AskForCollisionChoice; - } - if discovery_pending { - return CommandRoute::WaitForDiscovery; - } - if let Some(collision) = external.and_then(|command| command.native_collision.as_ref()) - { - return match collision.selected_candidate_id.as_deref() { - Some(selected) if selected == collision.external_candidate_id => { - CommandRoute::External - } - Some(selected) if selected == collision.native_candidate_id => { - CommandRoute::Builtin - } - _ => CommandRoute::AskForCollisionChoice, - }; - } - if has_builtin { - CommandRoute::Builtin - } else { - CommandRoute::External - } - } - } -} +include!("chat/external_review.rs"); +include!("chat/external_sources.rs"); fn agent_event_stream_failure(error: TryRecvError) -> Option { match error { @@ -1026,7 +140,7 @@ enum PendingMcpTask { #[derive(Default)] struct NonKeyEventOutcome { request_redraw: bool, - resize_seen: bool, + resize_observed: bool, } struct ChatEventContext<'a> { @@ -1117,4810 +231,15 @@ impl ChatMode { self.initial_prompt = Some(prompt); self } +} - fn external_conflict_preferences(&self) -> ExternalSourceConflictPreferences { - ExternalSourceConflictPreferences { - choices: self.external_source_conflict_choices.clone(), - lineage_current_keys: self.external_source_conflict_lineage_current_keys.clone(), - conflicted_candidate_ids: self.external_source_conflicted_candidate_ids.clone(), - } - } - - fn update_external_source_view( - &self, - chat_view: &mut ChatView, - snapshot: &ExternalSourceCatalogSnapshot, - ) { - let preferences = self.external_conflict_preferences(); - chat_view.set_external_source_state( - external_command_projections(snapshot, &preferences.choices), - snapshot.discovery_pending, - builtin_reconfirmation_names(&preferences), - ); - } - - fn take_external_tool_notice( - &mut self, - snapshot: &ExternalSourceCatalogSnapshot, - ) -> Option { - let next_key = external_tool_pending_notice_key(snapshot); - if next_key == self.external_tool_notice_key { - return None; - } - self.external_tool_notice_key = next_key; - let approvals = snapshot.tool_approval_requests.len(); - let conflicts = snapshot - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - let diagnostics = snapshot - .diagnostics - .iter() - .filter(|diagnostic| { - matches!( - diagnostic.severity, - ExternalSourceDiagnosticSeverity::Warning - | ExternalSourceDiagnosticSeverity::Error - ) - }) - .count(); - if approvals + conflicts + diagnostics == 0 { - None - } else { - Some(format!( - "External tools need attention: {approvals} approvals, {conflicts} name conflicts, {diagnostics} diagnostics - run /external-tools" - )) - } - } - - fn handle_external_tool_review( - &mut self, - arguments: &str, - chat_view: &mut ChatView, - chat_state: &ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let action = match parse_external_tool_review_action( - arguments, - self.external_source_snapshot.as_ref(), - self.external_tool_review_snapshot.as_ref(), - ) { - Ok(action) => action, - Err(error) => { - chat_view.set_status(Some(error)); - return; - } - }; - if matches!(action, ExternalToolReviewAction::Show) { - self.external_tool_review_snapshot = self.external_source_snapshot.clone(); - chat_view.show_info_popup(external_tool_review_text( - self.external_tool_review_snapshot.as_ref(), - )); - return; - } - - if self.external_tool_mutation_rx.is_some() { - chat_view.set_status(Some( - "An external tool update is already running; input and cancellation remain available." - .to_string(), - )); - return; - } - - let workspace = self.workspace_path_for_sync(chat_state); - let pending_status = match &action { - ExternalToolReviewAction::Refresh => "Refreshing external tools", - ExternalToolReviewAction::Decide { approved: true, .. } => "Enabling external tool", - ExternalToolReviewAction::Decide { - approved: false, .. - } => "Disabling external tool", - ExternalToolReviewAction::Choose { .. } => "Selecting external tool provider", - ExternalToolReviewAction::Show => unreachable!(), - }; - let task_action = action.clone(); - let (sender, receiver) = mpsc::channel(); - rt_handle.spawn(async move { - let result = match &task_action { - ExternalToolReviewAction::Refresh => { - external_source_snapshot(Some(&workspace), true).await - } - ExternalToolReviewAction::Decide { - approval_key, - decision_key, - approved, - } => { - set_external_tool_target_decision( - Some(&workspace), - approval_key, - decision_key, - *approved, - ) - .await - } - ExternalToolReviewAction::Choose { - conflict_key, - candidate_id, - } => { - set_external_tool_conflict_choice(Some(&workspace), conflict_key, candidate_id) - .await - } - ExternalToolReviewAction::Show => unreachable!(), - } - .map_err(|error| error.to_string()); - let _ = sender.send(ExternalToolMutationResult { - action: task_action, - result, - }); - }); - self.external_tool_mutation_rx = Some(receiver); - chat_view.set_status(Some(format!( - "{pending_status}; you can continue typing or cancel other UI work" - ))); - } - - fn poll_external_tool_mutation(&mut self, chat_view: &mut ChatView) -> bool { - let outcome = match self - .external_tool_mutation_rx - .as_ref() - .map(Receiver::try_recv) - { - Some(Ok(outcome)) => outcome, - Some(Err(MpscTryRecvError::Empty)) | None => return false, - 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." - .to_string(), - )); - return true; - } - }; - self.external_tool_mutation_rx = None; - match outcome.result { - Ok(snapshot) => { - if external_tool_result_is_stale(self.external_source_snapshot.as_ref(), &snapshot) - { - chat_view.set_status(Some( - "External tool update completed; a newer catalog result is already displayed." - .to_string(), - )); - return true; - } - self.update_external_source_view(chat_view, &snapshot); - self.external_tool_notice_key = external_tool_pending_notice_key(&snapshot); - let approvals = snapshot.tool_approval_requests.len(); - let conflicts = snapshot - .tool_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - let result_label = external_tool_mutation_result_label(&outcome.action, &snapshot); - self.external_source_snapshot = Some(snapshot); - if approvals + conflicts == 0 { - 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" - ))); - } - } - 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." - ))); - } - } - true - } - - fn replace_external_conflict_preferences( - &mut self, - preferences: ExternalSourceConflictPreferences, - ) { - self.external_source_conflict_choices = preferences.choices; - self.external_source_conflict_lineage_current_keys = preferences.lineage_current_keys; - self.external_source_conflicted_candidate_ids = preferences.conflicted_candidate_ids; - } - - fn workspace_path_for_sync(&self, chat_state: &ChatState) -> std::path::PathBuf { - chat_state - .workspace - .as_ref() - .map(std::path::PathBuf::from) - .or_else(|| self.workspace.clone().map(std::path::PathBuf::from)) - .or_else(|| std::env::current_dir().ok()) - .unwrap_or_else(|| std::path::PathBuf::from(".")) - } - - fn open_login_or_account_panel( - &self, - chat_view: &mut ChatView, - chat_state: &ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let logged_in = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); - if logged_in { - self.open_account_panel(chat_view, rt_handle); - } else { - chat_view.show_login_form(); - } - let _ = chat_state; - } - - fn open_account_panel(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { - let (info, devices, progress) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let info = crate::account::account_info().await; - let devices = crate::account::list_devices().await.unwrap_or_default(); - let progress = crate::account_sync::current_sync_progress().await; - (info, devices, progress) - }) - }); - match info { - Ok(info) => chat_view.show_account_panel(info, devices, progress), - Err(e) => { - chat_view.set_status(Some(format!("Failed to load account: {e}"))); - chat_view.show_login_form(); - } - } - } - - fn refresh_account_panel_live(&self, chat_view: &mut ChatView) { - if !chat_view.login_form_visible() { - return; - } - let progress = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(crate::account_sync::current_sync_progress()) - }); - let devices = if matches!( - progress.status, - crate::account_sync::SyncStatus::Syncing | crate::account_sync::SyncStatus::Done - ) { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::account::list_devices()) - .ok() - }) - } else { - None - }; - chat_view.update_account_panel_progress(devices, progress); - } - - fn start_sync_and_show_account( - &self, - is_first_login: bool, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let workspace = self.workspace_path_for_sync(chat_state); - crate::account_sync::start_auto_sync_background( - self.runtime.compatibility().clone(), - is_first_login, - workspace, - ); - self.open_account_panel(chat_view, rt_handle); - chat_state.add_system_message(if is_first_login { - "Sync started (use local / upload settings).".to_string() - } else { - "Sync started (use cloud / download settings).".to_string() - }); - } - - fn handle_login_form_action( - &self, - action: LoginFormAction, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - match action { - LoginFormAction::Submit(creds) => { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(crate::account::login_with_credentials( - &creds.relay_url, - &creds.username, - &creds.password, - )) - }); - match result { - Ok(login) => { - chat_state.add_system_message(login.status_message.clone()); - if login.has_cloud_settings { - chat_view.show_sync_choice_panel(&login.user_id, &login.relay_url); - } else { - self.start_sync_and_show_account( - true, chat_view, chat_state, rt_handle, - ); - } - } - Err(e) => { - chat_view.login_form_set_error(format!("Login failed: {e}")); - } - } - } - LoginFormAction::SyncUseLocal => { - self.start_sync_and_show_account(true, chat_view, chat_state, rt_handle); - } - LoginFormAction::SyncUseCloud => { - self.start_sync_and_show_account(false, chat_view, chat_state, rt_handle); - } - LoginFormAction::SyncCancel => { - let _ = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())); - chat_view.show_login_form(); - chat_state.add_system_message("Sync cancelled; logged out.".to_string()); - } - LoginFormAction::Logout => { - match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { - Ok(()) => { - chat_view.show_login_form(); - chat_state.add_system_message("Logged out.".to_string()); - } - Err(e) => { - chat_view.login_form_set_error(format!("Logout failed: {e}")); - } - } - } - LoginFormAction::Cancel => { - chat_view.set_status(Some("Account panel closed".to_string())); - } - LoginFormAction::None => {} - } - Ok(None) - } - - /// Check if any popup is currently visible - fn any_popup_visible(&self, chat_view: &ChatView) -> bool { - chat_view.command_palette_visible() - || chat_view.model_selector_visible() - || chat_view.agent_selector_visible() - || chat_view.session_selector_visible() - || chat_view.skill_selector_visible() - || chat_view.subagent_selector_visible() - || chat_view.mcp_selector_visible() - || chat_view.mcp_add_dialog_visible() - || chat_view.provider_selector_visible() - || chat_view.model_config_form_visible() - || chat_view.login_form_visible() - || chat_view.theme_selector_visible() - || chat_view.info_popup_visible() - } - - /// Close all popups and clear the navigation stack - fn close_all_popups(&self, chat_view: &mut ChatView) { - // Cancel theme preview if active - if chat_view.theme_selector_visible() { - chat_view.cancel_theme_preview(); - } - chat_view.hide_command_palette(); - chat_view.hide_model_selector(); - chat_view.hide_agent_selector(); - chat_view.hide_session_selector(); - chat_view.hide_skill_selector(); - chat_view.hide_subagent_selector(); - chat_view.hide_mcp_selector(); - chat_view.hide_mcp_add_dialog(); - chat_view.hide_provider_selector(); - chat_view.hide_model_config_form(); - chat_view.hide_login_form(); - chat_view.hide_theme_selector(); - chat_view.dismiss_info_popup(); - chat_view.popup_stack.clear(); - } - - /// Navigate back to the previous popup in the stack, or close all if at the root - fn navigate_back(&self, chat_view: &mut ChatView) { - // Pop the current popup from the stack and hide it - if let Some(current) = chat_view.popup_stack.pop() { - // Hide the current popup - match current { - crate::ui::chat::PopupType::CommandPalette => chat_view.hide_command_palette(), - crate::ui::chat::PopupType::ModelSelector => chat_view.hide_model_selector(), - crate::ui::chat::PopupType::AgentSelector => chat_view.hide_agent_selector(), - crate::ui::chat::PopupType::SessionSelector => chat_view.hide_session_selector(), - crate::ui::chat::PopupType::SkillSelector => chat_view.hide_skill_selector(), - crate::ui::chat::PopupType::SubagentSelector => chat_view.hide_subagent_selector(), - crate::ui::chat::PopupType::McpSelector => chat_view.hide_mcp_selector(), - crate::ui::chat::PopupType::McpAddDialog => chat_view.hide_mcp_add_dialog(), - crate::ui::chat::PopupType::ProviderSelector => chat_view.hide_provider_selector(), - crate::ui::chat::PopupType::ModelConfigForm => chat_view.hide_model_config_form(), - crate::ui::chat::PopupType::LoginForm => chat_view.hide_login_form(), - crate::ui::chat::PopupType::ThemeSelector => { - chat_view.hide_theme_selector(); - chat_view.cancel_theme_preview(); - } - crate::ui::chat::PopupType::InfoPopup => chat_view.dismiss_info_popup(), - } - - // If there's a previous popup in the stack, re-show it - if let Some(previous) = chat_view.popup_stack.peek() { - match previous { - crate::ui::chat::PopupType::CommandPalette => { - chat_view.reshow_command_palette() - } - crate::ui::chat::PopupType::ModelSelector => chat_view.reshow_model_selector(), - crate::ui::chat::PopupType::AgentSelector => chat_view.reshow_agent_selector(), - crate::ui::chat::PopupType::SessionSelector => { - chat_view.reshow_session_selector() - } - crate::ui::chat::PopupType::SkillSelector => chat_view.reshow_skill_selector(), - crate::ui::chat::PopupType::SubagentSelector => { - chat_view.reshow_subagent_selector() - } - crate::ui::chat::PopupType::McpSelector => chat_view.reshow_mcp_selector(), - crate::ui::chat::PopupType::McpAddDialog => chat_view.reshow_mcp_add_dialog(), - crate::ui::chat::PopupType::ProviderSelector => { - chat_view.reshow_provider_selector() - } - crate::ui::chat::PopupType::ModelConfigForm => { - chat_view.reshow_model_config_form() - } - crate::ui::chat::PopupType::LoginForm => chat_view.reshow_login_form(), - crate::ui::chat::PopupType::ThemeSelector => chat_view.reshow_theme_selector(), - crate::ui::chat::PopupType::InfoPopup => {} - } - } - } - } - - pub(crate) fn run( - &mut self, - existing_terminal: Option, - ) -> Result { - tracing::info!("Starting Chat mode, Agent: {}", self.agent_type); - if let Some(ws) = &self.workspace { - tracing::info!("Workspace: {}", ws); - } - - let mut terminal = match existing_terminal { - Some(t) => t, - None => init_terminal()?, - }; - - let appearance = resolve_appearance(&self.config.ui.theme); - let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); - let base_is_light = appearance.is_light(); - let base = match (base_is_light, scheme) { - (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), - (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), - (true, EffectiveColorScheme::Truecolor) => Theme::light(), - (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), - (false, EffectiveColorScheme::Truecolor) => Theme::dark(), - }; - let theme = self.resolve_configured_theme(base, appearance, scheme); - let shortcut_hints = self.keymap.compact_hints(ActionState::chat(false, false)); - let mut chat_view = ChatView::new(theme, shortcut_hints); - - // Create or restore core session - let rt_handle = tokio::runtime::Handle::current(); - - let (mut session_id, mut chat_state) = if let Some(ref restore_id) = self.restore_session_id - { - // Restore existing session - tracing::info!("Restoring session: {}", restore_id); - let agent = self.agent.clone(); - let rid = restore_id.clone(); - - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - // Restore session in core (loads metadata, messages, managers) - let (summary, effective_workspace_path) = - agent.restore_session_in_current_workspace(&rid).await?; - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); - - // Load historical messages for UI display - let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: rid.clone(), - messages: Vec::new(), - } - }); - - let state = ChatState::from_session_transcript( - rid.clone(), - summary.session_name, - summary.agent_type, - effective_workspace, - &transcript, - ); - - tracing::info!( - "Session restored: {}, {} messages loaded", - rid, - transcript.messages.len() - ); - - Ok::<_, anyhow::Error>((rid, state)) - }) - })? - } else { - // Create new session - let session_id = tokio::task::block_in_place(|| { - rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) - })?; - tracing::info!("Core session ready: {}", session_id); - - let state = ChatState::new( - session_id.clone(), - "CLI Session".to_string(), - self.agent_type.clone(), - self.workspace.clone(), - ); - (session_id, state) - }; - - // Keep ChatMode workspace in sync with the session's effective workspace - self.agent_type = chat_state.agent_type.clone(); - self.workspace = chat_state.workspace.clone(); - - let external_workspace = self.agent.workspace_path_buf(); - let (initial_external_sources, mut external_source_rx, conflict_preferences) = - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let updates = - subscribe_external_source_updates(Some(&external_workspace)).await; - let snapshot = external_source_snapshot(Some(&external_workspace), false).await; - let preferences = external_source_conflict_choices().await.map(Into::into); - (snapshot, updates.ok(), preferences) - }) - }); - match conflict_preferences { - Ok(preferences) => self.replace_external_conflict_preferences(preferences), - Err(error) => tracing::warn!("External source preferences are unavailable: {}", error), - } - match initial_external_sources { - Ok(snapshot) => { - let (available, restricted) = external_command_counts(&snapshot); - let pending_conflicts = snapshot - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - let tool_notice = self.take_external_tool_notice(&snapshot); - self.update_external_source_view(&mut chat_view, &snapshot); - self.external_source_snapshot = Some(snapshot.clone()); - if snapshot.discovery_pending { - chat_view.set_status(Some( - "Checking compatible content from external AI applications".to_string(), - )); - } else if let Some(notice) = tool_notice { - chat_view.set_status(Some(notice)); - } else if available + restricted > 0 || pending_conflicts > 0 { - chat_view.set_status(Some(format!( - "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" - ))); - } - } - Err(error) => { - tracing::warn!("External source discovery is unavailable: {}", error); - } - } - - // Load current model name for display - self.load_current_model_name(&mut chat_state, &rt_handle); - - if self.agent_type == "HarmonyOSDev" { - let deveco_home = std::env::var("DEVECO_HOME").ok(); - let missing = deveco_home - .as_deref() - .map(|s| s.trim().is_empty()) - .unwrap_or(true); - if missing { - chat_state.add_system_message( - "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." - .to_string(), - ); - } - } - - let mut event_rx = self.agent.event_source().subscribe(); - - // Send initial prompt if provided (from startup page input) - if let Some(prompt) = self.initial_prompt.take() { - tracing::info!("Sending initial prompt: {}", prompt); - if prompt.starts_with('/') { - // Slash commands will be handled in the main loop - chat_view.text_input.set_text(&prompt); - } else { - let display_name = agent_display_name(&self.agent_type); - chat_view.set_status(Some(format!("{} is thinking...", display_name))); - - let agent = self.agent.clone(); - let agent_type = self.agent_type.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.send_message(prompt, &agent_type)) - }) { - Ok(turn_id) => { - tracing::info!("Started initial turn: {}", turn_id); - } - Err(e) => { - tracing::error!("Failed to send initial prompt: {}", e); - chat_view.set_status(Some(format!("Error: {}", e))); - } - } - } - } - - let mut exit_reason = ChatExitReason::Quit; - let mut should_quit = false; - let mut needs_redraw = true; - let mut subagent_parent_tools: HashMap = HashMap::new(); - let mut last_spinner_redraw = Instant::now(); - let mut pending_resize_at: Option = None; - let mut event_reader = crate::ui::input::EventReader::default(); - let mut fatal_event_stream_error: Option = None; - let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); - let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); - - while !should_quit { - chat_view.set_action_state( - ActionState::chat(chat_state.is_processing, false), - &self.keymap, - ); - - // Coalesce rapid resize bursts before invalidating caches and redrawing. - if let Some(last_resize_at) = pending_resize_at { - if last_resize_at.elapsed() >= resize_redraw_debounce { - chat_view.invalidate_lines_cache(); - needs_redraw = true; - pending_resize_at = None; - } - } - - // Keep spinner animation smooth without forcing full redraw every loop. - // Pause spinner updates while resize is still being debounced. - if pending_resize_at.is_some() { - last_spinner_redraw = Instant::now(); - } else if chat_state.is_processing { - if last_spinner_redraw.elapsed() >= spinner_redraw_interval { - needs_redraw = true; - last_spinner_redraw = Instant::now(); - } - } else { - last_spinner_redraw = Instant::now(); - } - - // Poll completion of non-blocking MCP operations before rendering. - if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { - needs_redraw = true; - } - if self.poll_external_tool_mutation(&mut chat_view) { - needs_redraw = true; - } - - let mut external_source_closed = false; - if let Some(receiver) = external_source_rx.as_mut() { - let mut latest = None; - for _ in 0..4 { - match receiver.try_recv() { - Ok(snapshot) => latest = Some(snapshot), - Err(TryRecvError::Lagged(_)) => continue, - Err(TryRecvError::Empty) => break, - Err(TryRecvError::Closed) => { - external_source_closed = true; - break; - } - } - } - if let Some(snapshot) = latest { - let discovery_just_finished = self - .external_source_snapshot - .as_ref() - .is_some_and(|previous| previous.discovery_pending) - && !snapshot.discovery_pending; - let preferences = tokio::task::block_in_place(|| { - rt_handle - .block_on(external_source_conflict_choices()) - .map(Into::into) - }); - if let Ok(preferences) = preferences { - self.replace_external_conflict_preferences(preferences); - } - let tool_notice = self.take_external_tool_notice(&snapshot); - self.update_external_source_view(&mut chat_view, &snapshot); - if snapshot.discovery_pending { - chat_view.set_status(Some( - "Checking compatible content from external AI applications".to_string(), - )); - } else if let Some(notice) = tool_notice { - chat_view.set_status(Some(notice)); - } else if discovery_just_finished { - let (available, restricted) = external_command_counts(&snapshot); - let pending_conflicts = snapshot - .command_conflicts - .iter() - .filter(|conflict| conflict.selected_candidate_id.is_none()) - .count(); - chat_view.set_status(Some(format!( - "External sources ready: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" - ))); - } - self.external_source_snapshot = Some(snapshot); - needs_redraw = true; - } - } - if external_source_closed { - external_source_rx = None; - } - - if chat_view.login_form_visible() { - self.refresh_account_panel_live(&mut chat_view); - if crate::account_sync::sync_in_flight() { - needs_redraw = true; - } - } - - let mut did_render_this_loop = false; - if needs_redraw { - terminal.draw(|frame| { - chat_view.render(frame, &chat_state); - })?; - needs_redraw = false; - did_render_this_loop = true; - } - - // 1.5. Execute pending MCP operations (after render so loading state is visible) - if let Some(op) = self.pending_mcp_op.take() { - if !did_render_this_loop { - terminal.draw(|frame| { - chat_view.render(frame, &chat_state); - })?; - } - match op { - PendingMcpOp::Toggle(server_id) => { - self.execute_mcp_toggle( - &server_id, - &mut chat_view, - &mut chat_state, - &rt_handle, - ); - } - PendingMcpOp::Add { name, config_json } => { - self.execute_mcp_add( - &name, - &config_json, - &mut chat_view, - &mut chat_state, - &rt_handle, - ); - } - PendingMcpOp::Delete(server_id) => { - self.execute_mcp_delete( - &server_id, - &mut chat_view, - &mut chat_state, - &rt_handle, - ); - } - } - needs_redraw = true; - } - - // 2. Process core events (non-blocking) - let mut events = Vec::with_capacity(20); - for _ in 0..20 { - match event_rx.try_recv() { - Ok(envelope) => events.push(envelope), - Err(error) => { - let Some(mut failure) = agent_event_stream_failure(error) else { - break; - }; - - // The adapter records the turn before DialogTurnStarted reaches the UI, - // so cancellation must not depend on ChatState having seen that event. - let agent = self.agent.clone(); - if let Err(cancel_error) = tokio::task::block_in_place(|| { - rt_handle.block_on(agent.cancel_current_turn()) - }) { - failure = format!( - "{failure}; failed to cancel the active turn: {cancel_error}" - ); - } - mark_active_turn_failed(&mut chat_state, &failure); - chat_view.invalidate_lines_cache(); - chat_view.set_status(Some(format!("Error: {failure}"))); - tracing::error!("{failure}"); - fatal_event_stream_error = Some(failure); - break; - } - } - } - if fatal_event_stream_error.is_some() { - break; - } - for envelope in events { - let event = &envelope.event; - - if let AgenticEvent::SubagentSessionLinked { - session_id: subagent_session_id, - parent_session_id, - parent_tool_call_id, - .. - } = event - { - if parent_session_id == &session_id { - subagent_parent_tools - .insert(subagent_session_id.clone(), parent_tool_call_id.clone()); - } - continue; - } - - // Check if this is a subagent event that belongs to our session - if event.session_id() != Some(&session_id) { - // Check if this event was emitted by a subagent whose parent is in our session - if let Some(parent_tool_call_id) = event - .session_id() - .and_then(|event_session_id| subagent_parent_tools.get(event_session_id)) - { - // Forward subagent event to the parent Task tool for progress display - chat_state.handle_subagent_event(parent_tool_call_id, event); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } - continue; - } - - tracing::debug!("Processing core event: {:?}", event); - - match event { - AgenticEvent::DialogTurnStarted { - turn_id, - user_input, - .. - } => { - chat_state.handle_turn_started(turn_id, user_input); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } - - AgenticEvent::TextChunk { turn_id, text, .. } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_text_chunk(text); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } else { - tracing::debug!( - "Ignoring TextChunk for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - } - } - - AgenticEvent::ThinkingChunk { - turn_id, content, .. - } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_thinking_chunk(content); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } else { - tracing::debug!( - "Ignoring ThinkingChunk for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - } - } - - AgenticEvent::ToolEvent { - turn_id, - tool_event, - .. - } => { - if chat_state.current_turn_id() != Some(turn_id.as_str()) { - tracing::debug!( - "Ignoring ToolEvent for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - continue; - } - if let ToolEventData::ConfirmationNeeded { identity, .. } = tool_event { - if self - .runtime - .approval_controller() - .is_allowed(identity.effective_name()) - { - let agent = self.agent.clone(); - let tool_id = identity.tool_id.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => continue, - Err(error) => tracing::error!( - "Failed to confirm runtime-approved tool; showing the permission prompt again: {}", - error - ), - } - } - } - chat_state.handle_tool_event(tool_event); - chat_view.invalidate_lines_cache(); - needs_redraw = true; - } - - AgenticEvent::DialogTurnCompleted { - turn_id, - total_rounds, - total_tools, - .. - } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_turn_completed(*total_rounds, *total_tools); - chat_view.invalidate_lines_cache(); - chat_view.set_status(None); - needs_redraw = true; - tracing::info!("Dialog turn completed"); - } else { - tracing::debug!( - "Ignoring DialogTurnCompleted for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - } - } - - AgenticEvent::DialogTurnFailed { turn_id, error, .. } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_turn_failed(error); - chat_view.invalidate_lines_cache(); - chat_view.set_status(Some(format!("Error: {}", error))); - needs_redraw = true; - tracing::error!("Dialog turn failed: {}", error); - } else { - tracing::debug!( - "Ignoring DialogTurnFailed for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - } - } - - AgenticEvent::DialogTurnCancelled { turn_id, .. } => { - let active_turn_id = chat_state.current_turn_id(); - if active_turn_id.is_none() || active_turn_id == Some(turn_id.as_str()) { - chat_state.handle_turn_cancelled(); - chat_view.invalidate_lines_cache(); - chat_view.set_status(Some("Cancelled".to_string())); - needs_redraw = true; - tracing::info!("Dialog turn cancelled"); - } else { - tracing::debug!( - "Ignoring DialogTurnCancelled for non-active turn: active={:?}, event={}", - chat_state.current_turn_id(), - turn_id - ); - } - } - - AgenticEvent::TokenUsageUpdated { - turn_id, - total_tokens, - .. - } => { - if chat_state.current_turn_id() == Some(turn_id.as_str()) { - chat_state.handle_token_usage(*total_tokens); - needs_redraw = true; - } - } - - AgenticEvent::SystemError { error, .. } => { - chat_state.add_system_message(format!("[System error: {}]", error)); - chat_view.invalidate_lines_cache(); - chat_view.set_status(Some(format!("System error: {}", error))); - needs_redraw = true; - tracing::error!("System error: {}", error); - } - - // Other events we don't need to handle in the UI - _ => {} - } - } - - // 3. Process terminal input - if let Some(events) = event_reader.read_event_batch(Duration::from_millis(16))? { - for event in events { - match event { - Event::Key(key) => { - if let Some(reason) = self.handle_key_event( - key, - &mut chat_view, - &mut chat_state, - &rt_handle, - )? { - Self::apply_exit_reason( - reason, - ChatEventContext { - this: self, - chat_view: &mut chat_view, - chat_state: &mut chat_state, - session_id: &mut session_id, - rt_handle: &rt_handle, - should_quit: &mut should_quit, - exit_reason: &mut exit_reason, - }, - ); - } - if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { - needs_redraw = true; - } - } - other => { - let outcome = Self::handle_non_key_event( - other, - ChatEventContext { - this: self, - chat_view: &mut chat_view, - chat_state: &mut chat_state, - session_id: &mut session_id, - rt_handle: &rt_handle, - should_quit: &mut should_quit, - exit_reason: &mut exit_reason, - }, - )?; - if outcome.request_redraw { - needs_redraw = true; - } - if outcome.resize_seen { - pending_resize_at = Some(Instant::now()); - } - } - } - } - } - } - - let terminal_restore_result = restore_terminal(terminal); - if let Some(failure) = fatal_event_stream_error { - if let Err(restore_error) = terminal_restore_result { - return Err(anyhow!( - "{failure}; failed to restore the terminal: {restore_error}" - )); - } - return Err(anyhow!(failure)); - } - terminal_restore_result?; - tracing::info!("Chat mode exited"); - - Ok(exit_reason) - } - - fn handle_key_event( - &mut self, - key: KeyEvent, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { - return Ok(None); - } - - let modal_state = - ActionState::chat(chat_state.is_processing, self.any_popup_visible(chat_view)); - if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { - return self.dispatch_action(action, modal_state, chat_view, chat_state, rt_handle); - } - - // ── Permission prompt intercepts all keys when active ── - if let Some(ref mut prompt) = chat_state.permission_prompt { - let action = prompt.handle_key_event(key); - match action { - PermissionAction::AllowOnce => { - let tool_id = prompt.tool_id.clone(); - let agent = self.agent.clone(); - tracing::info!("User allowed tool once: {}", tool_id); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => { - chat_state.permission_prompt = None; - chat_view.set_status(Some("Tool confirmed".to_string())); - } - Err(error) => { - tracing::error!("Failed to confirm tool: {}", error); - chat_view.set_status(Some(format!("Error: {error}"))); - } - } - } - PermissionAction::AllowAlways => { - let tool_id = prompt.tool_id.clone(); - let tool_name = prompt.tool_name().to_string(); - let agent = self.agent.clone(); - tracing::info!( - "User allowed tool {}: tool_id={}, tool_name={}", - ALLOW_ALWAYS_RUNTIME_SCOPE, - tool_id, - tool_name - ); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.confirm_tool(&tool_id, None)) - }) { - Ok(()) => { - self.runtime.approval_controller().allow_always(&tool_name); - chat_state.permission_prompt = None; - chat_view.set_status(Some(format!( - "Tool approved {ALLOW_ALWAYS_RUNTIME_SCOPE}" - ))); - } - Err(error) => { - tracing::error!("Failed to confirm tool: {}", error); - chat_view.set_status(Some(format!("Error: {error}"))); - } - } - } - PermissionAction::Reject(reason) => { - let tool_id = prompt.tool_id.clone(); - let agent = self.agent.clone(); - tracing::info!("User rejected tool: {}, reason: {}", tool_id, reason); - let reason_clone = reason.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.reject_tool(&tool_id, reason_clone)) - }) { - Ok(()) => { - chat_state.permission_prompt = None; - chat_view.set_status(Some(format!("Tool rejected: {}", reason))); - } - Err(error) => { - tracing::error!("Failed to reject tool: {}", error); - chat_view.set_status(Some(format!("Error: {error}"))); - } - } - } - PermissionAction::None => { - // Permission prompt consumed the key, no further action - } - } - return Ok(None); - } - - // ── Question prompt intercepts all keys when active ── - if let Some(ref mut prompt) = chat_state.question_prompt { - let action = prompt.handle_key_event(key); - match action { - QuestionAction::Submit(answers) => { - let tool_id = prompt.tool_id.clone(); - let agent = self.agent.clone(); - chat_state.question_prompt = None; - tracing::info!("User submitted answers for tool: {}", tool_id); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(e) = agent.submit_user_answers(&tool_id, answers).await { - tracing::error!("Failed to submit answers: {}", e); - } - }) - }); - chat_view.set_status(Some("Answers submitted".to_string())); - } - QuestionAction::Reject => { - let tool_id = prompt.tool_id.clone(); - chat_state.question_prompt = None; - tracing::info!("User dismissed question prompt: {}", tool_id); - chat_view.set_status(Some("Question dismissed".to_string())); - } - QuestionAction::None => { - // Question prompt consumed the key, no further action - } - } - return Ok(None); - } - - // ── Normal key handling ── - - // Host recovery keys win over configured actions while a popup is open. - if self.any_popup_visible(chat_view) { - let state = ActionState::chat(chat_state.is_processing, true); - if let Some(action) = self.keymap.resolve_reserved(key, state) { - return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); - } - } - - // Info popup intercepts all keys when visible - if chat_view.info_popup_visible() { - match key.code { - KeyCode::Up => chat_view.info_popup_scroll_up(1), - KeyCode::Down => chat_view.info_popup_scroll_down(1), - KeyCode::PageUp => chat_view.info_popup_scroll_up(10), - KeyCode::PageDown => chat_view.info_popup_scroll_down(10), - KeyCode::Home => chat_view.info_popup_scroll_to_start(), - KeyCode::End => chat_view.info_popup_scroll_to_end(), - KeyCode::Esc => chat_view.dismiss_info_popup(), - _ => {} - } - return Ok(None); - } - - // Command palette intercepts all keys when visible - if chat_view.command_palette_visible() { - let action = chat_view.command_palette_handle_key(key); - match action { - PaletteAction::Execute(id) => { - return self.handle_palette_action(&id, chat_view, chat_state, rt_handle); - } - PaletteAction::Dismiss => self.navigate_back(chat_view), - PaletteAction::None => {} - } - return Ok(None); - } - - // Handle popup events first (when visible) - if chat_view.model_selector_visible() { - match key.code { - KeyCode::Up => chat_view.model_selector_up(), - KeyCode::Down => chat_view.model_selector_down(), - KeyCode::Enter => { - if let Some(selected) = chat_view.model_selector_confirm() { - chat_view.hide_model_selector(); - self.apply_model_selection(&selected, chat_view, chat_state, rt_handle); - } - } - KeyCode::Char('e') => { - if let Some(selected) = chat_view.model_selector_confirm() { - chat_view.hide_model_selector(); - self.edit_model(&selected, chat_view, rt_handle); - } - } - // Note: Esc is handled globally for navigation back - _ => {} - } - return Ok(None); - } - - if chat_view.theme_selector_visible() { - match key.code { - KeyCode::Up => { - chat_view.theme_selector_up(); - if let Some(selected) = chat_view.theme_selector_selected() { - self.preview_theme_selection(&selected, chat_view); - } - } - KeyCode::Down => { - chat_view.theme_selector_down(); - if let Some(selected) = chat_view.theme_selector_selected() { - self.preview_theme_selection(&selected, chat_view); - } - } - KeyCode::Enter => { - if let Some(selected) = chat_view.theme_selector_confirm() { - chat_view.hide_theme_selector(); - self.apply_theme_selection(&selected, chat_view); - chat_view.commit_theme_preview(); - } - } - // Note: Esc is handled globally for navigation back - _ => {} - } - return Ok(None); - } - - if chat_view.agent_selector_visible() { - match key.code { - 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); - } - } - // Note: Esc is handled globally for navigation back - _ => {} - } - return Ok(None); - } - - if chat_view.session_selector_visible() { - let action = chat_view.session_selector_handle_key(key); - match action { - SessionAction::Switch(item) => { - return Ok(Some(ChatExitReason::SwitchSession(item.session_id))); - } - SessionAction::Delete(item) => { - self.handle_session_delete(&item, chat_view, chat_state, rt_handle); - } - SessionAction::Close | SessionAction::None => {} - } - return Ok(None); - } - - if chat_view.skill_selector_visible() { - match key.code { - KeyCode::Up => chat_view.skill_selector_up(), - KeyCode::Down => chat_view.skill_selector_down(), - KeyCode::Enter | KeyCode::Char(' ') => { - if let Some(action) = chat_view.skill_selector_confirm() { - self.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); - } - } - // Note: Esc is handled globally for navigation back - _ => {} - } - return Ok(None); - } - - if chat_view.subagent_selector_visible() { - match key.code { - KeyCode::Up => chat_view.subagent_selector_up(), - KeyCode::Down => chat_view.subagent_selector_down(), - KeyCode::Enter | KeyCode::Char(' ') => { - if let Some(action) = chat_view.subagent_selector_confirm() { - self.handle_subagent_selector_action( - action, chat_view, chat_state, rt_handle, - ); - } - } - // Note: Esc is handled globally for navigation back - _ => {} - } - return Ok(None); - } - - if chat_view.mcp_selector_visible() { - match key.code { - KeyCode::Up => chat_view.mcp_selector_up(), - KeyCode::Down => chat_view.mcp_selector_down(), - KeyCode::Enter | KeyCode::Char(' ') => { - if let Some(selected) = chat_view.mcp_selector_confirm() { - self.toggle_mcp_server(&selected.id, chat_view); - } - } - KeyCode::Char('a') => { - // Open add dialog (hide selector first) - chat_view.hide_mcp_selector(); - chat_view.show_mcp_add_dialog(); - } - KeyCode::Char('d') => { - if let Some(selected) = chat_view.mcp_selector_confirm() { - // First press: enter confirm-delete mode - // Second press: actually delete (handled by confirm_delete state) - if chat_view.mcp_selector_is_confirm_delete(&selected.id) { - self.delete_mcp_server(&selected.id, chat_view); - } else { - chat_view.mcp_selector_start_confirm_delete(selected.id.clone()); - } - } - } - KeyCode::Char('e') => { - chat_view.hide_mcp_selector(); - self.open_mcp_config(chat_state); - } - // Note: Esc is handled globally for navigation back - _ => { - // Any other key cancels the confirm-delete state - chat_view.mcp_selector_cancel_confirm_delete(); - } - } - return Ok(None); - } - - if chat_view.mcp_add_dialog_visible() { - let action = chat_view.mcp_add_dialog_handle_key(key); - match action { - McpAddAction::Confirm { name, config_json } => { - self.add_mcp_server(&name, &config_json, chat_view); - } - McpAddAction::Cancel => { - // Re-open the MCP selector - self.show_mcp_selector(chat_view, chat_state, rt_handle); - } - McpAddAction::None => {} - } - return Ok(None); - } - - if chat_view.provider_selector_visible() { - if let Some(selection) = chat_view.provider_selector_handle_key(key) { - self.handle_provider_selection(selection, chat_view); - } - return Ok(None); - } - - if chat_view.model_config_form_visible() { - let action = chat_view.model_config_form_handle_key(key); - match action { - ModelFormAction::Save(result) => { - if result.editing_model_id.is_some() { - self.update_existing_model(result, chat_view, chat_state, rt_handle); - } else { - self.save_new_model(result, chat_view, chat_state, rt_handle); - } - } - ModelFormAction::Cancel => { - chat_view.set_status(Some("Model form cancelled".to_string())); - } - ModelFormAction::None => {} - } - return Ok(None); - } - - if chat_view.login_form_visible() { - self.refresh_account_panel_live(chat_view); - let action = chat_view.login_form_handle_key(key); - return self.handle_login_form_action(action, chat_view, chat_state, rt_handle); - } - - if let Some(action) = self - .keymap - .resolve(key, ActionState::chat(chat_state.is_processing, false)) - { - return self.dispatch_action( - action, - ActionState::chat(chat_state.is_processing, false), - chat_view, - chat_state, - rt_handle, - ); - } - - match (key.code, key.modifiers) { - (KeyCode::Backspace, _) => { - chat_view.handle_backspace(); - } - - (KeyCode::Left, _) => { - chat_view.move_cursor_left(); - } - (KeyCode::Right, _) => { - chat_view.move_cursor_right(); - } - - (KeyCode::Home, _) => { - chat_view.set_cursor_home(); - } - - (KeyCode::End, _) => { - chat_view.set_cursor_end(); - } - - (KeyCode::Esc, _) => { - if chat_view.browse_mode { - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("Exited browse mode".to_string())); - } - } - - (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) - if !c.is_control() && c != '\u{0}' => - { - chat_view.handle_char(c); - } - - _ => {} - } - - Ok(None) - } - - /// Apply an exit reason from handle_key_event (shared by normal and batch paths). - fn apply_exit_reason(reason: ChatExitReason, context: ChatEventContext<'_>) { - let ChatEventContext { - this, - chat_view, - chat_state, - session_id, - rt_handle, - should_quit, - exit_reason, - } = context; - match reason { - ChatExitReason::SwitchSession(new_session_id) => { - match this.switch_to_session( - &new_session_id, - session_id, - chat_state, - chat_view, - rt_handle, - ) { - Ok(()) => tracing::info!("Switched to session: {}", new_session_id), - Err(e) => { - chat_state.add_system_message(format!("Failed to switch session: {}", e)); - tracing::error!("Failed to switch session: {}", e); - } - } - } - ChatExitReason::NewSession => { - match this.create_new_session(session_id, chat_state, chat_view, rt_handle) { - Ok(()) => tracing::info!("Created new session: {}", session_id), - Err(e) => { - chat_state - .add_system_message(format!("Failed to create new session: {}", e)); - tracing::error!("Failed to create new session: {}", e); - } - } - } - other => { - *should_quit = true; - *exit_reason = other; - } - } - } - - /// Handle non-key events (Mouse, Paste, Resize, etc.). - fn handle_non_key_event( - event: Event, - context: ChatEventContext<'_>, - ) -> Result { - let mut outcome = NonKeyEventOutcome::default(); - match event { - Event::Mouse(mouse) => { - if context.chat_view.command_palette_captures_mouse(&mouse) { - let action = context.chat_view.command_palette_handle_mouse(&mouse); - match action { - PaletteAction::Execute(id) => { - if let Some(reason) = context.this.handle_palette_action( - &id, - context.chat_view, - context.chat_state, - context.rt_handle, - )? { - Self::apply_exit_reason( - reason, - ChatEventContext { - this: &mut *context.this, - chat_view: &mut *context.chat_view, - chat_state: &mut *context.chat_state, - session_id: &mut *context.session_id, - rt_handle: context.rt_handle, - should_quit: &mut *context.should_quit, - exit_reason: &mut *context.exit_reason, - }, - ); - } - } - PaletteAction::Dismiss => context.this.navigate_back(context.chat_view), - PaletteAction::None => {} - } - } else if context.chat_view.provider_selector_captures_mouse(&mouse) { - if let Some(selection) = - context.chat_view.provider_selector_handle_mouse(&mouse) - { - context - .this - .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_skill_action() { - context.this.handle_skill_selector_action( - action, - context.chat_view, - context.chat_state, - context.rt_handle, - ); - } - if let Some(action) = context.chat_view.take_pending_subagent_action() { - context.this.handle_subagent_selector_action( - action, - context.chat_view, - context.chat_state, - context.rt_handle, - ); - } - } else { - match mouse.kind { - MouseEventKind::ScrollUp => { - let total = context.chat_view.count_message_lines(context.chat_state); - context.chat_view.scroll_up(3, total); - } - MouseEventKind::ScrollDown => { - context.chat_view.scroll_down(3); - } - MouseEventKind::Down(MouseButton::Left) => { - let _ = context - .chat_view - .begin_mouse_selection(mouse.column, mouse.row); - } - MouseEventKind::Drag(MouseButton::Left) => { - let _ = context - .chat_view - .update_mouse_selection(mouse.column, mouse.row); - } - MouseEventKind::Up(MouseButton::Left) => { - match context - .chat_view - .complete_mouse_selection_or_click(mouse.column, mouse.row) - { - MouseGestureOutcome::CopyText(text) => { - match Clipboard::new().and_then(|mut cb| cb.set_text(text)) { - Ok(()) => context - .chat_view - .set_status(Some("Copied to clipboard".to_string())), - Err(_) => context.chat_view.set_status(Some( - "Failed to copy selection".to_string(), - )), - } - } - MouseGestureOutcome::Click(col, row) => { - context.chat_view.handle_mouse_click(col, row); - } - MouseGestureOutcome::None => {} - } - } - MouseEventKind::Moved - if !context - .chat_view - .update_mouse_selection(mouse.column, mouse.row) => - { - context.chat_view.handle_mouse_move(mouse.column, mouse.row); - } - _ => {} - } - } - if let Some(action_id) = context.chat_view.take_pending_command() { - if let Some(reason) = context.this.handle_action_id( - &action_id, - context.chat_view, - context.chat_state, - context.rt_handle, - )? { - Self::apply_exit_reason( - reason, - ChatEventContext { - this: &mut *context.this, - chat_view: &mut *context.chat_view, - chat_state: &mut *context.chat_state, - session_id: &mut *context.session_id, - rt_handle: context.rt_handle, - should_quit: &mut *context.should_quit, - exit_reason: &mut *context.exit_reason, - }, - ); - } - } - if let Some(theme) = context.chat_view.take_pending_theme_preview() { - context - .this - .preview_theme_selection(&theme, context.chat_view); - } - if let Some(server_id) = context.chat_view.take_pending_mcp_toggle() { - context - .this - .toggle_mcp_server(&server_id, context.chat_view); - } - outcome.request_redraw = true; - } - Event::Paste(text) => { - if context.chat_view.mcp_add_dialog_visible() { - context.chat_view.mcp_add_dialog_handle_paste(&text); - } else if context.chat_view.login_form_visible() { - context.chat_view.login_form_insert_paste(&text); - } else if context.chat_state.permission_prompt.is_none() - && context.chat_state.question_prompt.is_none() - && !context.this.any_popup_visible(context.chat_view) - { - context.chat_view.insert_paste(&text); - } - outcome.request_redraw = true; - } - Event::Resize(_, _) => { - outcome.resize_seen = true; - } - _ => {} - } - Ok(outcome) - } - - /// Handle command palette action - fn handle_palette_action( - &mut self, - action_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - // Hide command palette but keep it in stack for back navigation - // (unless the action switches away or exits) - let keep_in_stack = matches!(action_id, "new_session" | "exit"); - if !keep_in_stack { - chat_view.hide_command_palette(); - } - self.handle_action_id(action_id, chat_view, chat_state, rt_handle) - } - - fn handle_action_id( - &mut self, - action_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if let Some(external) = self.external_command_projection_for_action(action_id) { - return self.select_and_handle_external_command( - &external, "", chat_view, chat_state, rt_handle, - ); - } - let Some(action) = action_by_id(action_id, ActionContext::Chat) else { - 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, - ); - } - self.dispatch_action( - action, - ActionState::chat(chat_state.is_processing, false), - chat_view, - chat_state, - rt_handle, - ) - } - - /// Handle shortcut commands - fn handle_command( - &mut self, - command: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - let parts: Vec<&str> = command.split_whitespace().collect(); - if parts.is_empty() { - return Ok(None); - } - - let token = parts[0]; - let (qualifier, command_name) = parse_command_token(token); - let arguments = command - .get(token.len()..) - .map(str::trim_start) - .unwrap_or(""); - if let Some(candidate) = self.external_conflict_projection_for_alias(token) { - return self.select_and_handle_external_command( - &candidate, arguments, chat_view, chat_state, rt_handle, - ); - } - let builtin_alias = format!("/{command_name}"); - let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); - let mut external = self.external_command_projection(command_name); - let authoritative_preferences = tokio::task::block_in_place(|| { - rt_handle - .block_on(external_source_conflict_choices()) - .map(Into::into) - }); - if let Ok(authoritative_preferences) = authoritative_preferences { - if authoritative_preferences != self.external_conflict_preferences() { - self.replace_external_conflict_preferences(authoritative_preferences); - external = self.external_command_projection(command_name); - if let Some(snapshot) = &self.external_source_snapshot { - self.update_external_source_view(chat_view, snapshot); - } - } - } - let builtin_reconfirmation = builtin_action.and_then(|action| { - builtin_command_reconfirmation( - action.id, - action.name, - &self.external_conflict_preferences(), - ) - }); - if let Some(collision) = external - .as_ref() - .and_then(|command| command.native_collision.as_ref()) - .cloned() - { - if qualifier == CommandQualifier::External { - self.remember_native_command_choice( - &collision, - &collision.external_candidate_id, - chat_view, - rt_handle, - ); - } else if qualifier == CommandQualifier::Builtin { - self.remember_native_command_choice( - &collision, - &collision.native_candidate_id, - chat_view, - rt_handle, - ); - } - } else if qualifier == CommandQualifier::Builtin { - if let Some(reconfirmation) = builtin_reconfirmation - .as_ref() - .filter(|reconfirmation| !reconfirmation.confirmed) - { - self.remember_command_choice( - &reconfirmation.conflict_key, - &reconfirmation.candidate_id, - vec![reconfirmation.candidate_id.clone()], - chat_view, - rt_handle, - ); - } else if let Some(collision) = builtin_action - .and_then(|action| self.native_command_collision_for_action(action.id)) - { - let native_candidate_id = collision.native_candidate_id.clone(); - self.remember_native_command_choice( - &collision, - &native_candidate_id, - chat_view, - rt_handle, - ); - } - } - let builtin_reconfirmation_required = external.is_none() - && builtin_reconfirmation - .as_ref() - .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) - && qualifier != CommandQualifier::External - && (qualifier == CommandQualifier::Builtin - || (external.is_none() - && unresolved_candidates.is_empty() - && !builtin_reconfirmation_required)); - if can_route_external_tool_review { - self.handle_external_tool_review(arguments, chat_view, chat_state, rt_handle); - return Ok(None); - } - let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { - candidate - .native_collision - .as_ref() - .is_some_and(|collision| { - collision.selected_candidate_id.as_deref() - == Some(collision.native_candidate_id.as_str()) - }) - }); - if external.is_none() - && qualifier != CommandQualifier::Builtin - && !unresolved_candidates.is_empty() - && !native_choice_is_active - { - let mut choices = unresolved_candidates - .iter() - .map(|candidate| { - if candidate.restricted { - format!("{} (restricted)", candidate.invocation_alias) - } else { - candidate.invocation_alias.clone() - } - }) - .collect::>(); - if builtin_action.is_some() { - choices.insert(0, format!("/builtin:{command_name}")); - } - chat_state.add_system_message(format!( - "Command /{command_name} is provided by multiple sources. Choose one once: {}. The choice is remembered until a participant changes.", - choices.join(", ") - )); - return Ok(None); - } - let discovery_pending = self - .external_source_snapshot - .as_ref() - .is_some_and(|snapshot| snapshot.discovery_pending); - match command_route( - qualifier, - builtin_action.is_some(), - external.as_ref(), - discovery_pending, - builtin_reconfirmation_required, - ) { - CommandRoute::Builtin => { - let action = builtin_action.expect("route requires an available built-in action"); - self.dispatch_action( - action, - ActionState::chat(chat_state.is_processing, false), - chat_view, - chat_state, - rt_handle, - ) - } - CommandRoute::External => match self.handle_external_command( - command_name, - arguments, - external.as_ref(), - chat_view, - chat_state, - rt_handle, - ) { - 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] - )); - Ok(None) - } - Err(error) => Err(error), - }, - CommandRoute::AskForCollisionChoice => { - if builtin_reconfirmation_required { - chat_state.add_system_message(format!( - "The previous external candidate for /{command_name} changed or was removed. Use /builtin:{command_name} once to confirm the remaining BitFun command." - )); - } else { - chat_state.add_system_message(format!( - "Command /{command_name} is provided by BitFun and an external source. Choose /builtin:{command_name} or /external:{command_name}; the choice is remembered until the external command changes." - )); - } - Ok(None) - } - CommandRoute::WaitForDiscovery => { - let explicit = if builtin_action.is_some() { - format!(" Use /builtin:{command_name} to run the BitFun command now.") - } else { - String::new() - }; - chat_state.add_system_message(format!( - "BitFun is still checking compatible external commands.{explicit}" - )); - Ok(None) - } - CommandRoute::UnknownBuiltin => { - chat_state.add_system_message(format!( - "Unknown built-in command: /builtin:{command_name}\nUse /help or type / to see available commands" - )); - Ok(None) - } - } - } - - fn external_command_projection(&self, command_name: &str) -> Option { - external_command_projections( - self.external_source_snapshot.as_ref()?, - &self.external_source_conflict_choices, - ) - .into_iter() - .find(|command| { - command.provider_conflict_key.is_none() - && command.command_name.eq_ignore_ascii_case(command_name) - }) - } - - fn external_command_projection_for_action( - &self, - action_id: &str, - ) -> Option { - external_command_projections( - self.external_source_snapshot.as_ref()?, - &self.external_source_conflict_choices, - ) - .into_iter() - .find(|command| command.action_id == action_id) - } - - fn external_conflict_projection_for_alias( - &self, - token: &str, - ) -> Option { - external_command_projections( - self.external_source_snapshot.as_ref()?, - &self.external_source_conflict_choices, - ) - .into_iter() - .find(|command| { - command.provider_conflict_key.is_some() - && command.invocation_alias.eq_ignore_ascii_case(token) - }) - } - - fn external_conflict_projections(&self, command_name: &str) -> Vec { - self.external_source_snapshot - .as_ref() - .map(|snapshot| { - external_command_projections(snapshot, &self.external_source_conflict_choices) - .into_iter() - .filter(|command| { - command.provider_conflict_key.is_some() - && command.command_name.eq_ignore_ascii_case(command_name) - }) - .collect() - }) - .unwrap_or_default() - } - - fn native_command_collision_for_action( - &self, - action_id: &str, - ) -> Option { - external_command_projections( - self.external_source_snapshot.as_ref()?, - &self.external_source_conflict_choices, - ) - .into_iter() - .filter_map(|command| command.native_collision) - .find(|collision| collision.native_action_id == action_id) - } - - fn remember_native_command_choice( - &mut self, - collision: &NativeCommandCollisionProjection, - candidate_id: &str, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) { - self.remember_command_choice( - &collision.conflict_key, - candidate_id, - vec![ - collision.native_candidate_id.clone(), - collision.external_candidate_id.clone(), - ], - chat_view, - rt_handle, - ); - } - - fn remember_command_choice( - &mut self, - conflict_key: &str, - candidate_id: &str, - participants: Vec, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) { - let persisted = tokio::task::block_in_place(|| { - rt_handle.block_on(remember_external_source_conflict_choice( - conflict_key, - candidate_id, - participants.clone(), - )) - }); - match persisted { - Ok(preferences) => self.replace_external_conflict_preferences(preferences.into()), - Err(error) => { - tracing::warn!( - "Failed to persist external command conflict choice: {}", - error - ); - chat_view.set_status(Some( - "The command choice could not be saved; this explicit command will run once" - .to_string(), - )); - } - } - if let Some(snapshot) = &self.external_source_snapshot { - self.update_external_source_view(chat_view, snapshot); - } - } - - fn select_and_handle_external_command( - &mut self, - projection: &ExternalCommandProjection, - arguments: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if projection.restricted { - chat_state.add_system_message(format!( - "External command {} is currently restricted and cannot be selected.", - projection.invocation_alias - )); - return Ok(None); - } - if let Some(provider_conflict_key) = &projection.provider_conflict_key { - let workspace = self.agent.workspace_path_buf(); - let snapshot = tokio::task::block_in_place(|| { - rt_handle.block_on(set_external_prompt_command_conflict_choice( - Some(&workspace), - provider_conflict_key, - &projection.candidate_id, - )) - }); - let snapshot = match snapshot { - Ok(snapshot) => snapshot, - Err(error) => { - chat_state.add_system_message(format!( - "Could not select {}: {error}", - projection.invocation_alias - )); - return Ok(None); - } - }; - if let Some(collision) = &projection.native_collision { - self.remember_native_command_choice( - collision, - &projection.candidate_id, - chat_view, - rt_handle, - ); - } - self.external_source_snapshot = Some(snapshot); - let Some(active) = self.external_command_projection(&projection.command_name) else { - chat_state.add_system_message(format!( - "Selected external command /{} is no longer available; refresh and choose again.", - projection.command_name - )); - return Ok(None); - }; - if let Some(collision) = &active.native_collision { - self.remember_native_command_choice( - collision, - &active.candidate_id, - chat_view, - rt_handle, - ); - } - if let Some(snapshot) = &self.external_source_snapshot { - self.update_external_source_view(chat_view, snapshot); - } - return self.handle_external_command( - &projection.command_name, - arguments, - Some(&active), - chat_view, - chat_state, - rt_handle, - ); - } - if let Some(collision) = &projection.native_collision { - self.remember_native_command_choice( - collision, - &projection.candidate_id, - chat_view, - rt_handle, - ); - } - self.handle_external_command( - &projection.command_name, - arguments, - Some(projection), - chat_view, - chat_state, - rt_handle, - ) - } - - fn handle_external_command( - &mut self, - command_name: &str, - arguments: &str, - expected: Option<&ExternalCommandProjection>, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if chat_state.is_processing { - chat_view.set_status(Some( - "External prompt commands are unavailable while a turn is processing".to_string(), - )); - return Ok(None); - } - let workspace = self.agent.workspace_path_buf(); - let expanded = tokio::task::block_in_place(|| { - rt_handle.block_on(expand_external_prompt_command( - Some(&workspace), - command_name, - arguments, - expected.map(|command| command.candidate_id.as_str()), - expected.map(|command| command.content_version.as_str()), - )) - }); - match expanded { - Ok(expanded) => { - self.send_message_to_agent(expanded.content, chat_view, chat_state, rt_handle); - Ok(None) - } - Err(error) if error.contains("command not found") => Err(anyhow!(error)), - Err(error) => { - chat_state.add_system_message(format!( - "External command /{command_name} is unavailable: {error}" - )); - Ok(None) - } - } - } - - fn dispatch_action( - &mut self, - action: &'static ActionSpec, - state: ActionState, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if !action.available(state) { - chat_view.set_status(Some(action.unavailable_message(state))); - return Ok(None); - } - - match action.handler { - ActionHandler::Help => { - chat_view.show_info_popup(self.keymap.help_text(state)); - } - ActionHandler::ClearConversation => { - if chat_state.is_processing { - self.cancel_active_turn(chat_view, rt_handle); - } - chat_state.clear_messages(); - chat_view.clear_screen(); - chat_view.set_status(Some("Conversation cleared".to_string())); - } - ActionHandler::OpenAgentSelector => { - self.show_agent_selector(chat_view, chat_state, rt_handle); - } - ActionHandler::SwitchAgent => { - self.cycle_agent(chat_view, chat_state, rt_handle); - } - ActionHandler::SwitchAgentReverse => { - self.cycle_agent_reverse(chat_view, chat_state, rt_handle); - } - ActionHandler::SelectModel => { - self.show_model_selector(chat_view, chat_state, rt_handle); - } - ActionHandler::SelectTheme => { - let themes = self.list_available_themes(); - chat_view.begin_theme_preview(); - chat_view.show_theme_selector(themes, Some(self.config.ui.theme_id.clone())); - chat_view.set_status(Some( - "Theme selector: ↑↓ preview, Enter apply, Esc cancel".to_string(), - )); - } - ActionHandler::AddModel => chat_view.show_provider_selector(), - ActionHandler::NewSession => { - return Ok(Some(ChatExitReason::NewSession)); - } - ActionHandler::Sessions => { - self.show_session_selector(chat_view, chat_state, rt_handle); - } - ActionHandler::Skills => { - self.show_skill_selector(chat_view, chat_state, rt_handle); - } - 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 => { - self.handle_external_tool_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( - "ACP setup added to the conversation. You can keep typing.".to_string(), - )); - } - ActionHandler::Init => match crate::prompts::get_cli_prompt("init") { - Some(prompt) => { - self.send_message_to_agent(prompt.to_string(), chat_view, chat_state, rt_handle) - } - None => chat_state.add_system_message( - "Init prompt not found. Please create prompts/init.md in the CLI crate." - .to_string(), - ), - }, - ActionHandler::History => { - chat_state.add_system_message(format!( - "Current session statistics:\n\ - • Messages: {}\n\ - • Tool calls: {}\n\ - • Tokens: {}", - chat_state.metadata.message_count, - chat_state.metadata.tool_calls, - chat_state.metadata.total_tokens - )); - } - ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), - ActionHandler::Exit => { - if chat_state.is_processing { - self.cancel_active_turn(chat_view, rt_handle); - } - return Ok(Some(ChatExitReason::Quit)); - } - ActionHandler::Login => { - self.close_all_popups(chat_view); - self.open_login_or_account_panel(chat_view, chat_state, rt_handle); - } - ActionHandler::Logout => self.logout(chat_state, rt_handle), - ActionHandler::OpenPalette => chat_view.show_command_palette(state), - ActionHandler::SubmitInput => { - return self.submit_input(chat_view, chat_state, rt_handle); - } - ActionHandler::Interrupt => self.cancel_active_turn(chat_view, rt_handle), - ActionHandler::ClosePopups => self.close_all_popups(chat_view), - ActionHandler::NavigateBack => self.navigate_back(chat_view), - ActionHandler::InsertNewline => chat_view.handle_newline(), - ActionHandler::Paste => self.paste_clipboard(chat_view), - ActionHandler::ToggleFocusedTool => { - chat_view.toggle_focused_tool_expand(chat_state); - } - ActionHandler::PreviousTool => { - chat_view.cycle_block_tool_focus_prev(chat_state); - } - ActionHandler::NextTool => { - chat_view.cycle_block_tool_focus_next(chat_state); - } - ActionHandler::HistoryPrevious => { - if chat_view.command_menu_visible() { - chat_view.command_menu_up(); - } else { - chat_view.history_prev(); - } - } - ActionHandler::HistoryNext => { - if chat_view.command_menu_visible() { - chat_view.command_menu_down(); - } else { - chat_view.history_next(); - } - } - ActionHandler::JumpTop => { - let total = chat_view.count_message_lines(chat_state); - chat_view.scroll_to_top(total); - chat_view.set_status(Some("Jumped to conversation top".to_string())); - } - ActionHandler::JumpBottom => { - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("Jumped to conversation bottom".to_string())); - } - ActionHandler::ClearInput => chat_view.clear_input(), - ActionHandler::ToggleBrowse => { - chat_view.toggle_browse_mode(); - let status = if chat_view.browse_mode { - "Entered browse mode, use PageUp/PageDown or mouse wheel to scroll conversation" - } else { - "Exited browse mode" - }; - chat_view.set_status(Some(status.to_string())); - } - ActionHandler::ScrollUp => { - let total = chat_view.count_message_lines(chat_state); - chat_view.scroll_up(10, total); - } - ActionHandler::ScrollDown => chat_view.scroll_down(10), - } - Ok(None) - } - - fn submit_input( - &mut self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> Result> { - if let Some(action_id) = chat_view.apply_command_menu_selection() { - return self.handle_action_id(&action_id, chat_view, chat_state, rt_handle); - } - - if chat_state.is_processing { - let trimmed = chat_view.input_text().trim(); - if trimmed.starts_with('/') { - if let Some(input) = chat_view.send_input() { - return self.handle_command(&input, chat_view, chat_state, rt_handle); - } - } else if !trimmed.is_empty() { - chat_view.set_status(Some( - "Currently processing. Type a /command, or use the interrupt shortcut." - .to_string(), - )); - } - return Ok(None); - } - - if let Some(input) = chat_view.send_input() { - tracing::info!("User input: {}", input); - if input.starts_with('/') { - return self.handle_command(&input, chat_view, chat_state, rt_handle); - } - self.send_message_to_agent(input, chat_view, chat_state, rt_handle); - } - Ok(None) - } - - fn cancel_active_turn(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { - tracing::info!("User requested cancellation"); - let agent = self.agent.clone(); - tokio::task::block_in_place(|| { - rt_handle.block_on(async move { - if let Err(error) = agent.cancel_current_turn().await { - tracing::error!("Failed to cancel turn: {}", error); - } - }) - }); - chat_view.set_status(Some("Cancelling...".to_string())); - } - - fn paste_clipboard(&self, chat_view: &mut ChatView) { - if let Ok(text) = Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { - chat_view.insert_paste(&text); - } - } - - fn logout(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { - let logged_in = - tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); - if !logged_in { - chat_state.add_system_message("Not logged in.".to_string()); - return; - } - match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { - Ok(()) => chat_state.add_system_message("Logged out.".to_string()), - Err(error) => chat_state.add_system_message(format!("Logout failed: {error}")), - } - } - - fn show_usage_report( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - if chat_state.is_processing { - chat_view.set_status(Some( - "Wait until the session is idle before using /usage.".to_string(), - )); - return; - } - - let session_id = chat_state.core_session_id.clone(); - let workspace_path = chat_state - .workspace - .clone() - .or_else(|| self.workspace.clone()) - .or_else(|| Some(self.agent.workspace_path_string())); - let agent = self.agent.clone(); - - let report_result: Result = - tokio::task::block_in_place(|| { - let session_id = session_id.clone(); - let workspace_path = workspace_path.clone(); - let agent = agent.clone(); - rt_handle.block_on(async move { - let workspace_path = workspace_path - .filter(|path| !path.trim().is_empty()) - .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; - - let report = agent - .generate_session_usage_report(SessionUsageReportRequest { - session_id: session_id.clone(), - workspace_path: Some(workspace_path), - remote_connection_id: None, - remote_ssh_host: None, - include_hidden_subagents: true, - }) - .await?; - - let markdown = render_usage_report_markdown(&report); - let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); - let usage_report = serde_json::to_value(&report) - .map_err(|error| anyhow!("Failed to serialize usage report: {}", error))?; - let metadata = serde_json::json!({ - "localCommandKind": "usage_report", - "reportId": report.report_id.clone(), - "schemaVersion": report.schema_version, - "generatedAt": report.generated_at, - "modelVisible": false, - "usageReport": usage_report, - "usageReportStatus": "completed", - }); - - agent - .append_completed_local_command_turn( - &session_id, - markdown, - Some(format!("local-usage-{}", report.report_id)), - Some(generated_at), - Some(metadata), - ) - .await - .map_err(|error| anyhow!(error.to_string()))?; - - Ok(report) - }) - }); - - match report_result { - Ok(report) => { - let markdown = render_usage_report_markdown(&report); - chat_state.add_assistant_message(markdown); - chat_view.set_status(Some("Usage report added to conversation".to_string())); - } - Err(error) => { - chat_state - .add_system_message(format!("Failed to generate usage report: {}", error)); - } - } - } - - fn list_available_themes(&self) -> Vec { - let mut themes = Vec::new(); - for id in builtin_theme_ids() { - themes.push(ThemeItem { id }); - } - - themes.sort_by_cached_key(|theme| theme.id.to_ascii_lowercase()); - themes.dedup_by(|a, b| a.id == b.id); - themes - } - - fn resolve_configured_theme( - &self, - base: Theme, - appearance: Appearance, - scheme: EffectiveColorScheme, - ) -> Theme { - self.resolve_theme_by_id(base, appearance, scheme, self.config.ui.theme_id.trim()) - } - - fn resolve_theme_by_id( - &self, - base: Theme, - appearance: Appearance, - scheme: EffectiveColorScheme, - id: &str, - ) -> Theme { - if scheme == EffectiveColorScheme::Monochrome { - return Theme::monochrome(); - } - - if id.is_empty() { - return base; - } - - if let Some(json) = builtin_theme_json(id) { - return base - .apply_opencode_theme_json(json, appearance) - .unwrap_or(base) - .with_effective_scheme(scheme); - } - - base - } - - fn preview_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { - let appearance = resolve_appearance(&self.config.ui.theme); - let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); - let base_is_light = appearance.is_light(); - let base = match (base_is_light, scheme) { - (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), - (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), - (true, EffectiveColorScheme::Truecolor) => Theme::light(), - (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), - (false, EffectiveColorScheme::Truecolor) => Theme::dark(), - }; - - let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); - chat_view.set_theme(resolved); - chat_view.set_status(Some(format!( - "Preview theme: {} (Enter apply, Esc cancel)", - theme.id - ))); - } - - fn apply_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { - let appearance = resolve_appearance(&self.config.ui.theme); - let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); - let base_is_light = appearance.is_light(); - let base = match (base_is_light, scheme) { - (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), - (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), - (true, EffectiveColorScheme::Truecolor) => Theme::light(), - (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), - (false, EffectiveColorScheme::Truecolor) => Theme::dark(), - }; - - self.config.ui.theme_id = theme.id.clone(); - if let Err(e) = self.config.save() { - chat_view.set_status(Some(format!("Failed to save config: {}", e))); - } - - let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); - chat_view.set_theme(resolved); - chat_view.set_status(Some(format!("Theme set to: {}", theme.id))); - } - - fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { - let registry = get_agent_registry(); - let modes = tokio::task::block_in_place(|| rt_handle.block_on(registry.get_modes_info())); - modes - } - - fn cycle_agent( - &mut self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - self.switch_agent_by_offset(1, chat_view, chat_state, rt_handle); - } - - fn cycle_agent_reverse( - &mut self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - self.switch_agent_by_offset(-1, chat_view, chat_state, rt_handle); - } - - fn switch_agent_by_offset( - &mut self, - offset: isize, - _chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let modes = self.get_mode_agents(rt_handle); - if modes.len() <= 1 { - return; - } - - let current_idx = modes - .iter() - .position(|m| m.id == self.agent_type) - .unwrap_or(0); - - let len = modes.len() as isize; - let next_idx = ((current_idx as isize + offset) % len + len) % len; - let next = &modes[next_idx as usize]; - - self.agent_type = next.id.clone(); - chat_state.agent_type = next.id.clone(); - } - - /// Load current model name from global config for display - fn load_current_model_name( - &self, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let result: Option = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - - let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; - - fn provider_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - let raw_name = model.name.trim(); - let model_name = model.model_name.trim(); - if !raw_name.is_empty() && !model_name.is_empty() { - let dashed_suffix = format!(" - {}", model_name); - let slash_suffix = format!("/{}", model_name); - if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { - return provider.trim().to_string(); - } - if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { - return provider.trim().to_string(); - } - } - if raw_name.is_empty() { - model.provider.clone() - } else { - raw_name.to_string() - } - } - - fn model_display_name( - model: &bitfun_core::service::config::AIModelConfig, - ) -> String { - format!("{} / {}", model.model_name, provider_display_name(model)) - } - - let model_name = models - .iter() - .find(|model| model.id == model_id) - .map(model_display_name); - - model_name - }) - }); - - if let Some(name) = result { - chat_state.current_model_name = name; - } - } - - /// Show model selector popup with all available models - fn show_model_selector( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return None; - } - }; - - let models: Vec = - config_service.get_ai_models().await.ok()?; - let global_config: bitfun_core::service::config::GlobalConfig = - config_service.get_config(None).await.ok()?; - - let current_model_id = - crate::model_selection::resolve_mode_model_id(&global_config.ai); - - // Convert to ModelItem list (only enabled models) - let model_items: Vec = models - .into_iter() - .filter(|m| m.enabled) - .map(|m| ModelItem { - id: m.id, - name: m.name, - provider: m.provider, - model_name: m.model_name, - }) - .collect(); - - Some((model_items, current_model_id)) - }) - }); - - match result { - Some((models, current_id)) if !models.is_empty() => { - chat_view.show_model_selector(models, current_id); - } - _ => { - chat_state.add_system_message( - "No available models found. Please configure models first.".to_string(), - ); - } - } - } - - /// Apply model selection: update global config and chat state - fn apply_model_selection( - &self, - selected: &ModelItem, - _chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let selected_id = selected.id.clone(); - let selected_display_name = format!("{} / {}", selected.model_name, selected.name); - let session_id = chat_state.core_session_id.clone(); - - let success = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - if let Err(e) = self - .agent - .update_session_model(&session_id, &selected_id) - .await - { - tracing::error!("Failed to update current session model: {}", e); - return false; - } - - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service - .set_config("ai.agent_model_defaults.mode", &selected_id) - .await - { - tracing::error!("Failed to set future mode model: {}", e); - return false; - } - - true - }) - }); - - if success { - chat_state.current_model_name = selected_display_name.clone(); - tracing::info!( - "Model switched to: {} ({})", - selected_display_name, - selected_id - ); - } else { - tracing::error!( - "Failed to switch model: {} ({})", - selected_display_name, - selected_id - ); - } - } - - /// Show agent selector popup with all available agent modes - fn show_agent_selector( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let modes = self.get_mode_agents(rt_handle); - if modes.is_empty() { - chat_state.add_system_message("No mode agents available".to_string()); - return; - } - - let agent_items: Vec = modes - .into_iter() - .map(|m| AgentItem { - id: m.id, - description: m.description, - }) - .collect(); - - chat_view.show_agent_selector(agent_items, Some(self.agent_type.clone())); - } - - /// Apply agent selection: switch agent type - fn apply_agent_selection(&mut self, selected: &AgentItem, chat_state: &mut ChatState) { - if selected.id == self.agent_type { - return; - } - self.agent_type = selected.id.clone(); - chat_state.agent_type = selected.id.clone(); - tracing::info!("Switched to agent: {}", selected.id); - - if selected.id == "HarmonyOSDev" { - let deveco_home = std::env::var("DEVECO_HOME").ok(); - let missing = deveco_home - .as_deref() - .map(|s| s.trim().is_empty()) - .unwrap_or(true); - if missing { - chat_state.add_system_message( - "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." - .to_string(), - ); - } - } - } - - // ============ MCP management ============ - - /// Show MCP server selector popup - fn show_mcp_selector( - &self, - chat_view: &mut ChatView, - _chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let items = self.get_mcp_items(rt_handle); - // Show even if empty — user can press 'a' to add - chat_view.show_mcp_selector(items); - } - - /// Get MCP server items for display - fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc, - None => return Vec::new(), - }; - - let server_manager = mcp_service.server_manager(); - let config_service = mcp_service.config_service(); - - tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let configs = match config_service.load_all_configs().await { - Ok(c) => c, - Err(e) => { - tracing::error!("Failed to load MCP configs: {}", e); - return Vec::new(); - } - }; - - let tool_registry = - bitfun_core::agentic::tools::registry::get_global_tool_registry(); - let registry_lock = tool_registry.read().await; - let all_tools = registry_lock.get_all_tools(); - - let mut items = Vec::new(); - for config in configs { - let status = if !config.enabled { - "Stopped".to_string() - } else { - // Avoid blocking UI while a slow auto-start server holds internal write lock. - match tokio::time::timeout( - Duration::from_millis(30), - server_manager.get_server_status(&config.id), - ) - .await - { - Ok(Ok(s)) => format!("{:?}", s), - Ok(Err(_)) => "Unknown".to_string(), - Err(_) => "Starting".to_string(), - } - }; - - // Count tools from this server - let prefix = format!("mcp_{}_", config.id); - let tool_count = all_tools - .iter() - .filter(|t| t.name().starts_with(&prefix)) - .count(); - - let server_type = format!("{:?}", config.server_type).to_lowercase(); - - items.push(McpItem { - id: config.id.clone(), - name: config.name.clone(), - server_type, - status, - tool_count, - }); - } - items - }) - }) - } - - /// Schedule an MCP server toggle (deferred to allow loading state to render) - fn toggle_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { - if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { - return; - } - - // Set loading indicator immediately — will be rendered before execution - chat_view.mcp_selector_set_loading(Some(server_id.to_string())); - self.pending_mcp_op = Some(PendingMcpOp::Toggle(server_id.to_string())); - } - - /// Execute MCP server toggle (called from main loop after render) - fn execute_mcp_toggle( - &mut self, - server_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); - chat_view.mcp_selector_set_loading(None); - return; - } - }; - - let server_manager = mcp_service.server_manager(); - let task_server_id = server_id.to_string(); - let tracked_server_id = task_server_id.clone(); - - let handle = rt_handle.spawn(async move { - let status = server_manager.get_server_status(&task_server_id).await; - match status { - Ok(bitfun_core::service::mcp::MCPServerStatus::Connected) - | Ok(bitfun_core::service::mcp::MCPServerStatus::Healthy) => { - server_manager.stop_server(&task_server_id).await - } - _ => server_manager.start_server(&task_server_id).await, - } - }); - - self.pending_mcp_tasks.push(PendingMcpTask::Toggle { - server_id: tracked_server_id, - handle, - }); - } - - fn is_mcp_server_task_running(&self, server_id: &str) -> bool { - self.pending_mcp_tasks.iter().any(|task| match task { - PendingMcpTask::Toggle { server_id: id, .. } - | PendingMcpTask::Delete { server_id: id, .. } => id == server_id, - PendingMcpTask::Add { .. } => false, - }) - } - - fn has_pending_mcp_add_task(&self) -> bool { - self.pending_mcp_tasks - .iter() - .any(|task| matches!(task, PendingMcpTask::Add { .. })) - } - - fn poll_mcp_task_completion( - &mut self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) -> bool { - let mut changed = false; - let mut i = 0; - while i < self.pending_mcp_tasks.len() { - let finished = match &self.pending_mcp_tasks[i] { - PendingMcpTask::Toggle { handle, .. } - | PendingMcpTask::Add { handle, .. } - | PendingMcpTask::Delete { handle, .. } => handle.is_finished(), - }; - if !finished { - i += 1; - continue; - } - - let task = self.pending_mcp_tasks.swap_remove(i); - changed = true; - match task { - PendingMcpTask::Toggle { server_id, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => {} - Ok(Err(e)) => { - tracing::error!("Failed to toggle MCP server {}: {}", server_id, e); - chat_state.add_system_message(format!( - "Failed to toggle MCP server '{}': {}", - server_id, e - )); - } - Err(e) => { - tracing::error!("MCP toggle task join error for {}: {}", server_id, e); - chat_state.add_system_message(format!( - "MCP server '{}' task failed: {}", - server_id, e - )); - } - } - - chat_view.mcp_selector_set_loading(None); - let updated_items = self.get_mcp_items(rt_handle); - chat_view.mcp_selector_update_items(updated_items); - } - PendingMcpTask::Add { name, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => { - chat_state.add_system_message(format!( - "MCP server '{}' added and started", - name - )); - self.show_mcp_selector(chat_view, chat_state, rt_handle); - } - Ok(Err(e)) => { - chat_state - .add_system_message(format!("Failed to add MCP server: {}", e)); - } - Err(e) => { - chat_state.add_system_message(format!( - "MCP add task failed for '{}': {}", - name, e - )); - } - } - chat_view.set_status(None); - } - PendingMcpTask::Delete { server_id, handle } => { - let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); - - match join_result { - Ok(Ok(())) => { - chat_state - .add_system_message(format!("MCP server '{}' deleted", server_id)); - } - Ok(Err(e)) => { - chat_state - .add_system_message(format!("Failed to delete MCP server: {}", e)); - } - Err(e) => { - chat_state.add_system_message(format!( - "MCP delete task failed for '{}': {}", - server_id, e - )); - } - } - - chat_view.mcp_selector_set_loading(None); - let updated_items = self.get_mcp_items(rt_handle); - if updated_items.is_empty() { - chat_view.hide_mcp_selector(); - } else { - chat_view.mcp_selector_update_items(updated_items); - } - } - } - } - changed - } - - /// Schedule adding a new MCP server (deferred to allow loading state to render) - fn add_mcp_server(&mut self, name: &str, config_json_str: &str, chat_view: &mut ChatView) { - if self.pending_mcp_op.is_some() || self.has_pending_mcp_add_task() { - return; - } - - chat_view.set_status(Some(format!("Adding MCP server '{}'...", name))); - self.pending_mcp_op = Some(PendingMcpOp::Add { - name: name.to_string(), - config_json: config_json_str.to_string(), - }); - } - - /// Execute MCP server add (called from main loop after render) - fn execute_mcp_add( - &mut self, - name: &str, - config_json_str: &str, - _chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); - return; - } - }; - - let config_value: serde_json::Value = match serde_json::from_str(config_json_str) { - Ok(v) => v, - Err(e) => { - chat_state.add_system_message(format!("Invalid JSON: {}", e)); - _chat_view.set_status(None); - return; - } - }; - - let name_owned = name.to_string(); - let task_name = name_owned.clone(); - let handle = rt_handle.spawn(async move { - let config_obj = config_value.as_object().ok_or_else(|| { - bitfun_core::util::errors::BitFunError::Validation( - "MCP server config must be a JSON object".to_string(), - ) - })?; - - let server_type = match config_obj.get("type").and_then(|v| v.as_str()) { - Some("sse") => bitfun_core::service::mcp::MCPServerType::Remote, - Some("streamable-http") | Some("streamable_http") | Some("http") => { - bitfun_core::service::mcp::MCPServerType::Remote - } - _ => bitfun_core::service::mcp::MCPServerType::Local, - }; - - let transport = match config_obj.get("type").and_then(|v| v.as_str()) { - Some("sse") => bitfun_core::service::mcp::MCPServerTransport::Sse, - Some("streamable-http") | Some("streamable_http") | Some("http") => { - bitfun_core::service::mcp::MCPServerTransport::StreamableHttp - } - _ => bitfun_core::service::mcp::MCPServerTransport::Stdio, - }; - - let command = config_obj - .get("command") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let args = config_obj - .get("args") - .and_then(|v| v.as_array()) - .map(|values| { - values - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>() - }) - .unwrap_or_default(); - let env = config_obj - .get("env") - .and_then(|v| v.as_object()) - .map(|map| { - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect::>() - }) - .unwrap_or_default(); - let headers = config_obj - .get("headers") - .and_then(|v| v.as_object()) - .map(|map| { - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect::>() - }) - .unwrap_or_default(); - let url = config_obj - .get("url") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let auto_start = config_obj - .get("autoStart") - .or_else(|| config_obj.get("auto_start")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); - let enabled = config_obj - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - let config = bitfun_core::service::mcp::MCPServerConfig { - id: name_owned.clone(), - name: name_owned.clone(), - server_type, - transport: Some(transport), - command, - args, - env, - headers, - url, - auto_start, - enabled, - location: bitfun_core::service::mcp::ConfigLocation::User, - capabilities: Vec::new(), - settings: Default::default(), - oauth: config_obj - .get("oauth") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), - xaa: config_obj - .get("xaa") - .cloned() - .and_then(|value| serde_json::from_value(value).ok()), - }; - - mcp_service.server_manager().add_server(config).await?; - - Ok::<(), bitfun_core::util::errors::BitFunError>(()) - }); - self.pending_mcp_tasks.push(PendingMcpTask::Add { - name: task_name, - handle, - }); - } - - /// Schedule deleting an MCP server (deferred to allow loading state to render) - fn delete_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { - if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { - return; - } - - chat_view.mcp_selector_set_loading(Some(server_id.to_string())); - chat_view.mcp_selector_cancel_confirm_delete(); - self.pending_mcp_op = Some(PendingMcpOp::Delete(server_id.to_string())); - } - - /// Execute MCP server delete (called from main loop after render) - fn execute_mcp_delete( - &mut self, - server_id: &str, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let mcp_service = match crate::get_mcp_service() { - Some(svc) => svc.clone(), - None => { - chat_state.add_system_message("MCP service not initialized".to_string()); - chat_view.mcp_selector_set_loading(None); - return; - } - }; - - let server_id_owned = server_id.to_string(); - let task_server_id = server_id_owned.clone(); - let handle = rt_handle.spawn(async move { - // Delete config first so UI can reflect removal immediately even if stop is blocked. - mcp_service - .config_service() - .delete_server_config(&server_id_owned) - .await?; - - // Best-effort async cleanup: slow startups may hold process write lock for a long time. - // Retry stop with short timeout, without blocking the delete operation completion. - let cleanup_service = mcp_service.clone(); - let cleanup_server_id = server_id_owned.clone(); - tokio::spawn(async move { - for attempt in 1..=20 { - let stop_result = tokio::time::timeout( - Duration::from_millis(250), - cleanup_service - .server_manager() - .stop_server(&cleanup_server_id), - ) - .await; - - match stop_result { - Ok(Ok(())) => return, - Ok(Err(bitfun_core::util::errors::BitFunError::NotFound(_))) => return, - Ok(Err(e)) => { - tracing::debug!( - "Best-effort MCP stop failed: id={} attempt={} error={}", - cleanup_server_id, - attempt, - e - ); - } - Err(_) => { - tracing::debug!( - "Best-effort MCP stop timed out: id={} attempt={}", - cleanup_server_id, - attempt - ); - } - } - - tokio::time::sleep(Duration::from_millis(250)).await; - } - - tracing::warn!( - "Best-effort MCP stop exhausted retries: id={}", - cleanup_server_id - ); - }); - - Ok::<(), bitfun_core::util::errors::BitFunError>(()) - }); - - self.pending_mcp_tasks.push(PendingMcpTask::Delete { - server_id: task_server_id, - handle, - }); - } - - /// Open MCP config file in system editor or show its path - fn open_mcp_config(&self, chat_state: &mut ChatState) { - match bitfun_core::infrastructure::try_get_path_manager_arc() { - Ok(path_manager) => { - let config_file = path_manager.app_config_file(); - chat_state.add_system_message(format!( - "MCP servers are configured in:\n {}\n\n\ - Edit the \"mcp_servers\" section. Example (Cursor format):\n\ - {{\n \"mcp_servers\": {{\n \"mcpServers\": {{\n \ - \"my-server\": {{\n \"type\": \"stdio\",\n \ - \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-xxx\"]\n \ - }}\n }}\n }}\n}}", - config_file.display() - )); - } - Err(_) => { - chat_state.add_system_message( - "Could not determine config file path. Check ~/.config/bitfun/config/app.json" - .to_string(), - ); - } - } - } - - /// Switch to a different session: restore it from core, reload messages, update state - fn switch_to_session( - &mut self, - new_session_id: &str, - session_id: &mut String, - chat_state: &mut ChatState, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) -> Result<()> { - let agent = self.agent.clone(); - let sid = new_session_id.to_string(); - - let (new_state, restored_agent_type) = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let (session_summary, effective_workspace_path) = - agent.restore_session_in_current_workspace(&sid).await?; - let restored_agent_type = session_summary.agent_type.clone(); - let effective_workspace = - Some(effective_workspace_path.to_string_lossy().to_string()); - - // Load historical messages through the runtime transcript contract. - let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { - bitfun_agent_runtime::sdk::SessionTranscript { - session_id: sid.clone(), - messages: Vec::new(), - } - }); - - let state = ChatState::from_session_transcript( - sid.clone(), - session_summary.session_name, - restored_agent_type.clone(), - effective_workspace, - &transcript, - ); - - Ok::<_, anyhow::Error>((state, restored_agent_type)) - }) - })?; - - // Update session state - *session_id = new_session_id.to_string(); - *chat_state = new_state; - self.agent_type = restored_agent_type; - self.workspace = chat_state.workspace.clone(); - - // Reload model name - self.load_current_model_name(chat_state, rt_handle); - - // Reset view state - chat_view.scroll_to_bottom(); - chat_view.set_status(Some(format!("Switched to session: {}", new_session_id))); - - Ok(()) - } - - /// Create a new session: reset state and start fresh - fn create_new_session( - &mut self, - session_id: &mut String, - chat_state: &mut ChatState, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) -> Result<()> { - let agent = self.agent.clone(); - let agent_type = self.agent_type.clone(); - let workspace = self.workspace.clone(); - - let new_session_id = tokio::task::block_in_place(|| { - rt_handle.block_on(agent.create_new_session(&agent_type)) - })?; - - let new_state = ChatState::new( - new_session_id.clone(), - "CLI Session".to_string(), - agent_type, - workspace, - ); - - *session_id = new_session_id; - *chat_state = new_state; - self.workspace = chat_state.workspace.clone(); - - // Reload model name - self.load_current_model_name(chat_state, rt_handle); - - // Reset view state - chat_view.clear_screen(); - chat_view.scroll_to_bottom(); - chat_view.set_status(Some("New session created".to_string())); - - Ok(()) - } - - /// Show skill list/configuration menu. - fn show_skill_selector( - &self, - chat_view: &mut ChatView, - _chat_state: &mut ChatState, - _rt_handle: &tokio::runtime::Handle, - ) { - chat_view.show_skill_menu(); - } - - /// Re-scan skill directories from disk and rebuild the registry cache. - /// - /// Mirrors Claude Code 2.1.152 `/reload-skills`. Safe to call at any - /// time — does not require `is_processing` to be false because the - /// registry swap is atomic and a held `SkillInfo` reference is not - /// kept across the call. - fn reload_skills_from_disk( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let registry = SkillRegistry::global(); - let workspace = self.agent.workspace_path_buf(); - let outcome = tokio::task::block_in_place(|| { - // refresh() is the global re-scan entry point; the workspace - // arg of refresh_for_workspace is currently a no-op upstream, - // so we call refresh() directly and re-resolve the workspace - // count afterwards. - rt_handle.block_on(async { - registry.refresh().await; - registry - .get_resolved_skills_for_workspace(Some(workspace.as_path()), None) - .await - }) - }); - - let count = outcome.len(); - chat_state.add_system_message(format!("Reloaded {} skill(s) from disk.", count)); - chat_view.set_status(Some(format!("Skills reloaded ({} available)", count))); - } - - fn show_available_skill_list( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let skills = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(async { - let registry = SkillRegistry::global(); - registry - .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) - .await - }) - }); - - if skills.is_empty() { - chat_state.add_system_message(format!( - "No enabled skills found for agent mode '{}'. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/, or enable built-in skills for this mode.", - self.agent_type - )); - return; - } - - let skill_items: Vec = - skills.into_iter().map(Self::skill_item_from_info).collect(); - - if skill_items.is_empty() { - chat_state.add_system_message("No skills found.".to_string()); - return; - } - - chat_view.show_skill_list(skill_items); - } - - fn show_skill_config_selector( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let skills = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(async { - let registry = SkillRegistry::global(); - registry - .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) - .await - }) - }); - - let skill_items: Vec = skills - .into_iter() - .map(Self::skill_item_from_mode_info) - .collect(); - - if skill_items.is_empty() { - chat_state.add_system_message("No skills found.".to_string()); - return; - } - - chat_view.show_skill_config(skill_items); - } - - fn handle_skill_selector_action( - &self, - action: SkillSelectorAction, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - match action { - SkillSelectorAction::ListSkills => { - self.show_available_skill_list(chat_view, chat_state, rt_handle); - } - SkillSelectorAction::ConfigureSkills => { - self.show_skill_config_selector(chat_view, chat_state, rt_handle); - } - SkillSelectorAction::Execute(selected) => { - chat_view.hide_skill_selector(); - self.apply_skill_selection(&selected, chat_view); - } - SkillSelectorAction::Toggle(selected) => { - self.set_skill_enabled(&selected, !selected.enabled, chat_state, rt_handle); - self.show_skill_config_selector(chat_view, chat_state, rt_handle); - } - } - } - - /// Apply skill selection: fill input box with execution command - fn apply_skill_selection(&self, selected: &SkillItem, chat_view: &mut ChatView) { - chat_view.set_input(&format!("Execute the {} skill.", selected.name)); - } - - fn set_skill_enabled( - &self, - selected: &SkillItem, - enabled: bool, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let workspace = self.agent.workspace_path_buf(); - let mode_id = self.agent_type.clone(); - let skill = selected.clone(); - - let result: Result<(), String> = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - match skill.level.as_str() { - "user" => { - set_user_mode_skill_state( - &mode_id, - &skill.key, - enabled, - skill.default_enabled, - ) - .await - .map_err(|error| error.to_string())?; - } - "project" => { - let mut document = load_project_mode_skills_document_local(&workspace) - .await - .map_err(|error| error.to_string())?; - set_mode_skill_disabled_in_document( - &mut document, - &mode_id, - &skill.key, - !enabled, - ) - .map_err(|error| error.to_string())?; - save_project_mode_skills_document_local(&workspace, &document) - .await - .map_err(|error| error.to_string())?; - } - other => { - return Err(format!("Unsupported skill level '{}'", other)); - } - } - - Ok(()) - }) - }); - - match result { - Ok(()) => chat_state.add_system_message(format!( - "Skill '{}' {} for mode '{}'.", - selected.name, - if enabled { "enabled" } else { "disabled" }, - self.agent_type - )), - Err(error) => chat_state.add_system_message(format!( - "Failed to update skill '{}': {}", - selected.name, error - )), - } - } - - fn skill_item_from_info(info: SkillInfo) -> SkillItem { - SkillItem { - key: info.key, - name: info.name, - description: info.description, - level: info.level.as_str().to_string(), - enabled: true, - selected_for_runtime: true, - default_enabled: true, - is_shadowed: info.is_shadowed, - } - } - - fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { - SkillItem { - key: info.skill.key, - name: info.skill.name, - description: info.skill.description, - level: info.skill.level.as_str().to_string(), - enabled: info.effective_enabled, - selected_for_runtime: info.selected_for_runtime, - default_enabled: info.default_enabled, - is_shadowed: info.skill.is_shadowed, - } - } - - /// Show subagent list/configuration menu. - fn show_subagent_selector( - &self, - chat_view: &mut ChatView, - _chat_state: &mut ChatState, - _rt_handle: &tokio::runtime::Handle, - ) { - chat_view.show_subagent_menu(); - } - - fn show_available_subagent_list( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let registry = get_agent_registry(); - let subagents = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::TaskVisible, - include_disabled: false, - })) - }); - - if subagents.is_empty() { - chat_state.add_system_message(format!( - "No enabled subagents found for agent mode '{}'.", - self.agent_type - )); - return; - } - - let subagent_items: Vec = subagents - .into_iter() - .map(Self::subagent_item_from_info) - .collect(); - - if subagent_items.is_empty() { - chat_state.add_system_message("No subagents found.".to_string()); - return; - } - - chat_view.show_subagent_list(subagent_items); - } - - fn show_subagent_config_selector( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let registry = get_agent_registry(); - let subagents = tokio::task::block_in_place(|| { - let workspace = self.agent.workspace_path_buf(); - let agent_type = self.agent_type.clone(); - rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { - parent_agent_type: Some(&agent_type), - workspace_root: Some(workspace.as_path()), - list_scope: SubagentListScope::RegistryManagement, - include_disabled: true, - })) - }); - - let subagent_items: Vec = subagents - .into_iter() - .map(Self::subagent_item_from_info) - .collect(); - - if subagent_items.is_empty() { - chat_state.add_system_message("No subagents found.".to_string()); - return; - } - - chat_view.show_subagent_config(subagent_items); - } - - fn handle_subagent_selector_action( - &self, - action: SubagentSelectorAction, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - match action { - SubagentSelectorAction::ListSubagents => { - self.show_available_subagent_list(chat_view, chat_state, rt_handle); - } - SubagentSelectorAction::ConfigureSubagents => { - self.show_subagent_config_selector(chat_view, chat_state, rt_handle); - } - SubagentSelectorAction::Launch(selected) => { - chat_view.hide_subagent_selector(); - self.apply_subagent_selection(&selected, chat_view); - } - SubagentSelectorAction::Toggle(selected) => { - self.set_subagent_enabled(&selected, !selected.enabled, chat_state, rt_handle); - self.show_subagent_config_selector(chat_view, chat_state, rt_handle); - } - } - } - - /// Apply subagent selection: fill input box with launch command - fn apply_subagent_selection(&self, selected: &SubagentItem, chat_view: &mut ChatView) { - chat_view.set_input(&format!( - "Launch subagent {} to finish task: ", - selected.name - )); - } - - fn set_subagent_enabled( - &self, - selected: &SubagentItem, - enabled: bool, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let registry = get_agent_registry(); - let workspace = self.agent.workspace_path_buf(); - let mode_id = self.agent_type.clone(); - let subagent = selected.clone(); - - let result: Result<(), String> = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - registry - .update_subagent_override( - &mode_id, - &subagent.id, - enabled, - Some(workspace.as_path()), - ) - .await - .map_err(|error| error.to_string()) - }) - }); - - match result { - Ok(()) => chat_state.add_system_message(format!( - "Subagent '{}' {} for mode '{}'.", - selected.name, - if enabled { "enabled" } else { "disabled" }, - self.agent_type - )), - Err(error) => chat_state.add_system_message(format!( - "Failed to update subagent '{}': {}", - selected.name, error - )), - } - } - - fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { - let source = match info.subagent_source { - Some(SubAgentSource::Builtin) => "builtin", - Some(SubAgentSource::Project) => "project", - Some(SubAgentSource::User) => "user", - None => "builtin", - } - .to_string(); - - SubagentItem { - key: info.key, - id: info.id, - name: info.name, - description: info.description, - source, - enabled: info.effective_enabled, - } - } - - /// Send a message to the agent programmatically (used by slash commands like /init) - fn send_message_to_agent( - &self, - message: String, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - if chat_state.is_processing { - chat_state.add_system_message("Already processing, please wait.".to_string()); - return; - } - - let display_name = agent_display_name(&self.agent_type); - chat_view.set_status(Some(format!("{} is thinking...", display_name))); - - let agent = self.agent.clone(); - let agent_type = self.agent_type.clone(); - match tokio::task::block_in_place(|| { - rt_handle.block_on(agent.send_message(message, &agent_type)) - }) { - Ok(turn_id) => { - tracing::info!("Started turn: {}", turn_id); - } - Err(e) => { - tracing::error!("Failed to send message: {}", e); - chat_view.set_status(Some(format!("Error: {}", e))); - } - } - } - - /// Show session selector popup with all available sessions - fn show_session_selector( - &self, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let agent = self.agent.clone(); - let current_session_id = chat_state.core_session_id.clone(); - - let sessions = tokio::task::block_in_place(|| { - rt_handle.block_on(async { agent.list_sessions().await.unwrap_or_default() }) - }); - - if sessions.is_empty() { - chat_state.add_system_message("No sessions found.".to_string()); - return; - } - - let session_items: Vec = sessions - .into_iter() - .map(|s| { - let last_activity = { - let last_activity = - std::time::UNIX_EPOCH + Duration::from_millis(s.last_active_at_ms); - let elapsed = last_activity.elapsed().unwrap_or_default(); - if elapsed.as_secs() < 60 { - "just now".to_string() - } else if elapsed.as_secs() < 3600 { - format!("{}m ago", elapsed.as_secs() / 60) - } else if elapsed.as_secs() < 86400 { - format!("{}h ago", elapsed.as_secs() / 3600) - } else { - format!("{}d ago", elapsed.as_secs() / 86400) - } - }; - SessionItem { - session_id: s.session_id, - session_name: s.session_name, - last_activity, - workspace: self.workspace.clone(), - } - }) - .collect(); - - chat_view.show_session_selector(session_items, Some(current_session_id)); - } - - /// Handle session deletion from the session selector - fn handle_session_delete( - &self, - item: &SessionItem, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - // Prevent deleting the currently active session - if item.session_id == chat_state.core_session_id { - chat_view.set_status(Some("Cannot delete the active session".to_string())); - return; - } - - let agent = self.agent.clone(); - let sid = item.session_id.clone(); - - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { agent.delete_session(&sid).await }) - }); - - match result { - Ok(()) => { - chat_view.session_selector_remove_item(&item.session_id); - chat_view.set_status(Some(format!("Session deleted: {}", item.session_name))); - tracing::info!("Deleted session: {}", item.session_id); - } - Err(e) => { - chat_view.set_status(Some(format!("Failed to delete session: {}", e))); - tracing::error!("Failed to delete session: {}", e); - } - } - } - - /// Handle provider selection result (step 1 → step 2) - fn handle_provider_selection(&self, selection: ProviderSelection, chat_view: &mut ChatView) { - match selection { - ProviderSelection::Provider(template) => { - let default_model = template.models.first().cloned().unwrap_or_default(); - chat_view.show_model_config_form_from_provider( - &template.name, - &template.base_url, - &template.format, - &default_model, - ); - } - ProviderSelection::Custom => { - chat_view.show_model_config_form_custom(); - } - } - } - - /// Save new model to global config - fn save_new_model( - &self, - result: ModelFormResult, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let model_id = format!( - "model_{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - ); - - // Parse custom headers JSON if provided - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) - }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() - }; - - let success = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service.add_ai_model(model_config).await { - tracing::error!("Failed to add AI model: {}", e); - return false; - } - - // Auto-set as primary model if no primary model exists - match config_service - .get_config::(None) - .await - { - Ok(global_config) => { - let has_primary = global_config - .ai - .default_models - .primary - .as_ref() - .map(|p| !p.is_empty()) - .unwrap_or(false); - if !has_primary { - if let Err(e) = config_service - .set_config("ai.default_models.primary", &model_id) - .await - { - tracing::warn!("Failed to auto-set primary model: {}", e); - } else { - tracing::info!("Auto-set primary model: {}", model_id); - } - } - } - Err(e) => { - tracing::warn!("Failed to read config for auto-primary: {}", e); - } - } - - true - }) - }); - - if success { - chat_view.set_status(Some(format!("Model added: {}", result.name))); - chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); - tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); - } else { - chat_view.set_status(Some("Failed to add model".to_string())); - } - } - - /// Fetch full model config and open the edit form - fn edit_model( - &self, - selected: &ModelItem, - chat_view: &mut ChatView, - rt_handle: &tokio::runtime::Handle, - ) { - let model_id = selected.id.clone(); - let result = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = GlobalConfigManager::get_service().await.ok()?; - let models: Vec = - config_service.get_ai_models().await.ok()?; - models.into_iter().find(|m| m.id == model_id) - }) - }); - - match result { - Some(model) => { - let form_data = ModelFormResult { - editing_model_id: Some(model.id.clone()), - name: model.name, - model_name: model.model_name, - base_url: model.base_url, - api_key: model.api_key, - provider_format: model.provider.clone(), - context_window: model.context_window.unwrap_or(128000), - max_tokens: model.max_tokens.unwrap_or(8192), - enable_thinking: model.enable_thinking_process, - support_preserved_thinking: model.inline_think_in_text, - skip_ssl_verify: model.skip_ssl_verify, - custom_headers: model - .custom_headers - .map(|h| serde_json::to_string(&h).unwrap_or_default()) - .unwrap_or_default(), - custom_headers_mode: model - .custom_headers_mode - .unwrap_or_else(|| "merge".to_string()), - custom_request_body: model.custom_request_body.unwrap_or_default(), - }; - chat_view.show_model_config_form_for_edit(&model.id, &form_data); - } - None => { - chat_view.set_status(Some("Failed to load model configuration".to_string())); - } - } - } - - /// Update an existing model in global config - fn update_existing_model( - &self, - result: ModelFormResult, - chat_view: &mut ChatView, - chat_state: &mut ChatState, - rt_handle: &tokio::runtime::Handle, - ) { - let model_id = match &result.editing_model_id { - Some(id) => id.clone(), - None => return, - }; - - let custom_headers: Option> = - if result.custom_headers.is_empty() { - None - } else { - serde_json::from_str(&result.custom_headers).ok() - }; - - let custom_request_body: Option = if result.custom_request_body.is_empty() { - None - } else { - Some(result.custom_request_body.clone()) - }; - - let model_config = bitfun_core::service::config::AIModelConfig { - id: model_id.clone(), - name: result.name.clone(), - provider: result.provider_format.clone(), - model_name: result.model_name.clone(), - base_url: result.base_url.clone(), - api_key: result.api_key.clone(), - context_window: Some(result.context_window), - max_tokens: Some(result.max_tokens), - enabled: true, - enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, - skip_ssl_verify: result.skip_ssl_verify, - custom_headers, - custom_headers_mode: if result.custom_headers_mode.is_empty() - || result.custom_headers_mode == "merge" - { - None - } else { - Some(result.custom_headers_mode.clone()) - }, - custom_request_body, - ..Default::default() - }; - - let success = tokio::task::block_in_place(|| { - rt_handle.block_on(async { - let config_service = match GlobalConfigManager::get_service().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get config service: {}", e); - return false; - } - }; - - if let Err(e) = config_service - .update_ai_model(&model_id, model_config) - .await - { - tracing::error!("Failed to update AI model: {}", e); - return false; - } - - true - }) - }); - - if success { - chat_view.set_status(Some(format!("Model updated: {}", result.name))); - chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); - tracing::info!("Updated AI model: {}", model_id); - } else { - chat_view.set_status(Some("Failed to update model".to_string())); - } - } -} - -#[cfg(test)] -mod tests { - use tokio::sync::broadcast::error::TryRecvError; - - use super::{ - agent_event_stream_failure, builtin_command_reconfirmation, command_route, - external_command_projections, external_tool_mutation_result_label, - external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, - mark_active_turn_failed, parse_command_token, parse_external_tool_review_action, - CommandQualifier, CommandRoute, ExternalSourceConflictPreferences, - ExternalToolReviewAction, - }; - use crate::actions::{ActionState, ResolvedKeymap}; - use crate::chat_state::ChatState; - use crate::config::ShortcutsConfig; - use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; - use bitfun_core::external_sources::{ - ExternalSourceCatalogSnapshot, ExternalToolActivationState, - }; - use std::collections::{BTreeMap, BTreeSet}; - - fn external_command( - name: &str, - selected_candidate_id: Option<&str>, - ) -> ExternalCommandProjection { - ExternalCommandProjection { - action_id: format!("external-command:{name}"), - command_name: name.to_string(), - invocation_alias: format!("/{name}"), - candidate_id: format!("external:{name}"), - content_version: "v1".to_string(), - description: "External command".to_string(), - restricted: false, - provider_conflict_key: None, - native_collision: Some(NativeCommandCollisionProjection { - native_action_id: name.to_string(), - native_candidate_id: format!("bitfun.cli:{name}"), - external_candidate_id: format!("external:{name}"), - conflict_key: "conflict-v1".to_string(), - selected_candidate_id: selected_candidate_id.map(str::to_string), - }), - } - } - - fn external_tool_review_snapshot() -> ExternalSourceCatalogSnapshot { - serde_json::from_value(serde_json::json!({ - "generation": 3, - "discoveryPending": false, - "sources": [{ - "stableKey": "opencode-tools-project", - "record": { - "key": { "providerId": "opencode.tools", "sourceId": "project" }, - "ecosystemId": "opencode", - "displayName": "OpenCode project tools", - "sourceKind": "tools", - "scope": "project", - "location": "D:/repo/.opencode/tools", - "executionDomainId": "local:D:/repo", - "health": "available", - "contentVersion": "source-v1" - }, - "lifecycle": "available" - }], - "commands": [], - "tools": [{ - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "review.js" - }, - "exportId": "default" - }, - "name": "review", - "descriptionPreview": "Review a change", - "modulePath": "D:/repo/.opencode/tools/review.js", - "workingDirectory": "D:/repo", - "runtimeKind": "java_script", - "capabilities": ["file_system", "network", "environment", "process"], - "contentVersion": "content-v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v1", - "decisionKey": "decision-v1", - "activation": { "state": "approval_required" } - }, { - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "weather.js" - }, - "exportId": "default" - }, - "name": "weather", - "descriptionPreview": "Read weather", - "modulePath": "D:/repo/.opencode/tools/weather.js", - "workingDirectory": "D:/repo", - "runtimeKind": "java_script", - "capabilities": ["network"], - "contentVersion": "content-v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v2", - "decisionKey": "decision-v2", - "activation": { "state": "disabled" } - }, { - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "deploy.js" - }, - "exportId": "default" - }, - "name": "deploy", - "descriptionPreview": "Deploy a build", - "modulePath": "D:/repo/.opencode/tools/deploy.js", - "workingDirectory": "D:/repo", - "runtimeKind": "java_script", - "capabilities": ["process"], - "contentVersion": "content-v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v3", - "decisionKey": "decision-v3", - "activation": { "state": "active" } - }, { - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "broken.ts" - }, - "exportId": "default" - }, - "name": "broken", - "descriptionPreview": "Broken tool", - "modulePath": "D:/repo/.opencode/tools/broken.ts", - "workingDirectory": "D:/repo", - "runtimeKind": "type_script", - "capabilities": ["file_system"], - "contentVersion": "content-v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v4", - "decisionKey": "decision-v4", - "activation": { - "state": "load_failed", - "reason": "PR2 worker could not import the module" - } - }], - "toolApprovalRequests": [{ - "approvalKey": "approval-v1", - "decisionKey": "decision-v1", - "targetId": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "review.js" - }, - "sourceDisplayName": "OpenCode project tools", - "sourceScope": "project", - "sourceLocation": "D:/repo/.opencode/tools/review.js", - "workingDirectory": "D:/repo", - "runtimeKind": "java_script", - "capabilities": ["file_system", "network", "environment", "process"], - "contentVersion": "content-v1", - "toolNames": ["review"] - }], - "toolConflicts": [{ - "conflictKey": "conflict-v1", - "toolName": "review", - "candidates": [{ - "candidateId": "bitfun:review", - "displayName": "BitFun review", - "kind": "built_in", - "providerId": "bitfun", - "contentVersion": "builtin-v1" - }, { - "candidateId": "external:review", - "displayName": "OpenCode review", - "kind": "external", - "providerId": "opencode.tools", - "contentVersion": "content-v1", - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "sourceLocation": "D:/repo/.opencode/tools/review.js" - }] - }], - "diagnostics": [{ - "severity": "warning", - "code": "opencode.tool.directory_read_failed", - "message": "PR2 worker could not read one tool directory", - "source": { "providerId": "opencode.tools", "sourceId": "project" } - }] - })) - .unwrap() - } - - #[test] - fn external_tool_review_summary_discloses_execution_boundary_and_commands() { - let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); - - assert!(summary.contains("No external code ran during discovery")); - assert!(summary.contains("filesystem, network, process, environment variables")); - assert!(summary.contains("inherited environment variables")); - assert!(summary.contains("full descendant-process cleanup")); - assert!(summary.contains("/external-tools enable 1")); - assert!(summary.contains("/external-tools choose 1 2")); - assert!(summary.contains("D:/repo/.opencode/tools/review.js")); - assert!(summary.contains("Source root: D:/repo/.opencode/tools")); - assert!(summary.contains("Scope: project")); - assert!(summary.contains("Execution domain: local:D:/repo")); - assert!(summary.contains("disabled")); - assert!(summary.contains("active")); - assert!(summary.contains("loaded successfully")); - assert!(summary.contains("load failed")); - assert!(summary.contains("D:/repo/.opencode/tools/broken.ts")); - assert!(summary.contains("Diagnostics")); - assert!(summary.contains("opencode.tool.directory_read_failed")); - assert!(!summary.contains("PR2")); - } - - #[test] - fn external_tool_review_commands_resolve_indices_to_stable_keys() { - let snapshot = external_tool_review_snapshot(); - - assert_eq!( - parse_external_tool_review_action("enable 2", Some(&snapshot), None).unwrap(), - ExternalToolReviewAction::Decide { - approval_key: "approval-v2".to_string(), - decision_key: "decision-v2".to_string(), - approved: true, - } - ); - assert_eq!( - parse_external_tool_review_action("disable 3", Some(&snapshot), None).unwrap(), - ExternalToolReviewAction::Decide { - approval_key: "approval-v3".to_string(), - decision_key: "decision-v3".to_string(), - approved: false, - } - ); - assert_eq!( - parse_external_tool_review_action("disable 4", Some(&snapshot), None).unwrap(), - ExternalToolReviewAction::Decide { - approval_key: "approval-v4".to_string(), - decision_key: "decision-v4".to_string(), - approved: false, - } - ); - assert_eq!( - parse_external_tool_review_action("choose 1 2", Some(&snapshot), None).unwrap(), - ExternalToolReviewAction::Choose { - conflict_key: "conflict-v1".to_string(), - candidate_id: "external:review".to_string(), - } - ); - assert!(parse_external_tool_review_action("enable 3", Some(&snapshot), None).is_err()); - } - - #[test] - fn external_tool_review_commands_keep_the_indices_from_the_displayed_review() { - let reviewed = external_tool_review_snapshot(); - let mut current = reviewed.clone(); - current.tools.swap(0, 1); - - assert_eq!( - parse_external_tool_review_action("enable 2", Some(¤t), Some(&reviewed)).unwrap(), - ExternalToolReviewAction::Decide { - approval_key: "approval-v2".to_string(), - decision_key: "decision-v2".to_string(), - approved: true, - } - ); - } - - #[test] - fn external_tool_enable_result_reports_the_returned_activation() { - let mut snapshot = external_tool_review_snapshot(); - snapshot.tools[0].activation = ExternalToolActivationState::LoadFailed { - reason: "module import failed".to_string(), - }; - let action = ExternalToolReviewAction::Decide { - approval_key: "approval-v1".to_string(), - decision_key: "decision-v1".to_string(), - approved: true, - }; - - assert_eq!( - external_tool_mutation_result_label(&action, &snapshot), - "External tool approved, but loading failed" - ); - } - - #[test] - fn external_tool_notice_key_changes_for_pending_decisions_or_diagnostics() { - let snapshot = external_tool_review_snapshot(); - let key = external_tool_pending_notice_key(&snapshot).unwrap(); - let mut generation_only = snapshot.clone(); - generation_only.generation += 1; - assert_eq!( - external_tool_pending_notice_key(&generation_only), - Some(key.clone()) - ); - - generation_only.tool_approval_requests[0].decision_key = "decision-v2".to_string(); - assert_ne!( - external_tool_pending_notice_key(&generation_only), - Some(key.clone()) - ); - - let mut diagnostic_change = snapshot; - diagnostic_change.diagnostics[0].message = "different failure".to_string(); - assert_ne!( - external_tool_pending_notice_key(&diagnostic_change), - Some(key) - ); - } - - #[test] - fn external_tool_mutation_result_does_not_overwrite_a_newer_catalog_generation() { - let incoming = external_tool_review_snapshot(); - let mut current = incoming.clone(); - current.generation += 1; - - assert!(external_tool_result_is_stale(Some(¤t), &incoming)); - assert!(!external_tool_result_is_stale(Some(&incoming), ¤t)); - assert!(!external_tool_result_is_stale(None, &incoming)); - } - - #[test] - fn explicit_builtin_never_falls_through_to_an_external_command() { - let external = external_command("review", None); - assert_eq!( - command_route( - CommandQualifier::Builtin, - false, - Some(&external), - false, - false, - ), - CommandRoute::UnknownBuiltin - ); - } - - #[test] - fn command_qualifiers_are_ascii_case_insensitive() { - assert_eq!( - parse_command_token("/BUILTIN:help"), - (CommandQualifier::Builtin, "help") - ); - assert_eq!( - parse_command_token("/External:review"), - (CommandQualifier::External, "review") - ); - } - - #[test] - fn unresolved_provider_conflicts_expose_explicit_cli_choices() { - let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ - "generation": 1, - "discoveryPending": false, - "sources": [ - { - "stableKey": "first", - "record": { - "key": { "providerId": "first.commands", "sourceId": "global" }, - "ecosystemId": "first", - "displayName": "First commands", - "sourceKind": "prompt_commands", - "scope": "user_global", - "location": "/first", - "executionDomainId": "local-user", - "health": "available", - "contentVersion": "source-v1" - }, - "lifecycle": "available" - }, - { - "stableKey": "second", - "record": { - "key": { "providerId": "second.commands", "sourceId": "global" }, - "ecosystemId": "second", - "displayName": "Second commands", - "sourceKind": "prompt_commands", - "scope": "user_global", - "location": "/second", - "executionDomainId": "local-user", - "health": "available", - "contentVersion": "source-v1" - }, - "lifecycle": "available" - } - ], - "commands": [], - "commandConflicts": [{ - "conflictKey": "provider-conflict-v1", - "commandName": "review", - "candidates": [ - { - "candidateId": "first-candidate", - "source": { "providerId": "first.commands", "sourceId": "global" }, - "sourceDisplayName": "First commands", - "ecosystemId": "first", - "contentVersion": "command-v1", - "commandDescription": "First review", - "sourceScope": "user_global", - "sourceLocation": "/first", - "availability": { "state": "available" } - }, - { - "candidateId": "second-candidate", - "source": { "providerId": "second.commands", "sourceId": "global" }, - "sourceDisplayName": "Second commands", - "ecosystemId": "second", - "contentVersion": "command-v1", - "commandDescription": "Second review", - "sourceScope": "user_global", - "sourceLocation": "/second", - "availability": { "state": "available" } - } - ] - }] - })) - .unwrap(); - - let projections = external_command_projections(&snapshot, &BTreeMap::new()); - - assert_eq!(projections.len(), 2); - assert!(projections.iter().all(|projection| { - projection.provider_conflict_key.as_deref() == Some("provider-conflict-v1") - })); - assert!(projections - .iter() - .any(|projection| projection.invocation_alias == "/external:first.commands:review")); - assert!(projections - .iter() - .any(|projection| projection.invocation_alias == "/external:second.commands:review")); - } - - #[test] - fn native_collision_requires_one_choice_and_then_reuses_it() { - let unresolved = external_command("help", None); - assert_eq!( - command_route( - CommandQualifier::Unqualified, - true, - Some(&unresolved), - false, - false, - ), - CommandRoute::AskForCollisionChoice - ); - let selected = external_command("help", Some("external:help")); - assert_eq!( - command_route( - CommandQualifier::Unqualified, - true, - Some(&selected), - false, - false, - ), - CommandRoute::External - ); - } - - #[test] - fn discovery_pending_requires_an_explicit_command_qualifier() { - assert_eq!( - command_route(CommandQualifier::Unqualified, true, None, true, false,), - CommandRoute::WaitForDiscovery - ); - assert_eq!( - command_route(CommandQualifier::Builtin, true, None, true, false), - CommandRoute::Builtin - ); - } - - #[test] - fn removed_external_candidate_requires_builtin_reconfirmation() { - assert_eq!( - command_route(CommandQualifier::Unqualified, true, None, false, true,), - CommandRoute::AskForCollisionChoice - ); - assert_eq!( - command_route(CommandQualifier::Builtin, true, None, false, true), - CommandRoute::Builtin - ); - } - - #[test] - fn persisted_collision_history_detects_a_removed_external_candidate() { - let action = - crate::actions::action_for_alias("/help", crate::actions::ActionContext::Chat).unwrap(); - let mut preferences = ExternalSourceConflictPreferences { - choices: BTreeMap::new(), - lineage_current_keys: BTreeMap::new(), - conflicted_candidate_ids: BTreeSet::from([ - "bitfun.cli:help".to_string(), - "external:help".to_string(), - ]), - }; - - let pending = builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); - assert!(!pending.confirmed); - - preferences - .choices - .insert(pending.conflict_key.clone(), pending.candidate_id.clone()); - let confirmed = - builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); - assert!(confirmed.confirmed); - } - - #[test] - fn agent_event_stream_failure_ignores_empty_queue() { - assert_eq!(agent_event_stream_failure(TryRecvError::Empty), None); - } - - #[test] - fn agent_event_stream_failure_treats_lagged_and_closed_as_fatal() { - let lagged = agent_event_stream_failure(TryRecvError::Lagged(7)) - .expect("lagged stream must be fatal"); - assert!(lagged.contains("lagged by 7 events")); - assert!(lagged.contains("can no longer be trusted")); - - let closed = - agent_event_stream_failure(TryRecvError::Closed).expect("closed stream must be fatal"); - assert!(closed.contains("closed")); - assert!(closed.contains("can no longer be trusted")); - } - - #[test] - fn agent_event_stream_failure_marks_active_turn_failed() { - let mut state = ChatState::new( - "session".to_string(), - "Session".to_string(), - "agentic".to_string(), - Some("D:/workspace/current".to_string()), - ); - state.handle_turn_started("turn", "hello"); - - assert!(mark_active_turn_failed( - &mut state, - "Agent event stream closed; chat state can no longer be trusted" - )); - assert_eq!(state.current_turn_id(), None); - assert!(!state.is_processing); - } - - #[test] - fn shortcut_registry_contract_help_uses_resolved_keymap() { - let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); - - let help = keymap.help_text(ActionState::chat(false, false)); - assert!(help.contains("Ctrl+P")); - assert!(help.contains("Command Palette")); - } -} +include!("chat/account.rs"); +include!("chat/run.rs"); +include!("chat/input.rs"); +include!("chat/commands.rs"); +include!("chat/selection.rs"); +include!("chat/mcp.rs"); +include!("chat/sessions.rs"); +include!("chat/capabilities.rs"); +include!("chat/provider_models.rs"); +include!("chat/tests.rs"); diff --git a/src/apps/cli/src/modes/chat/account.rs b/src/apps/cli/src/modes/chat/account.rs new file mode 100644 index 0000000000..a783828294 --- /dev/null +++ b/src/apps/cli/src/modes/chat/account.rs @@ -0,0 +1,254 @@ +impl ChatMode { + fn replace_external_conflict_preferences( + &mut self, + preferences: ExternalSourceConflictPreferences, + ) { + self.external_source_conflict_choices = preferences.choices; + self.external_source_conflict_lineage_current_keys = preferences.lineage_current_keys; + self.external_source_conflicted_candidate_ids = preferences.conflicted_candidate_ids; + } + + fn workspace_path_for_sync(&self, chat_state: &ChatState) -> std::path::PathBuf { + chat_state + .workspace + .as_ref() + .map(std::path::PathBuf::from) + .or_else(|| self.workspace.clone().map(std::path::PathBuf::from)) + .or_else(|| std::env::current_dir().ok()) + .unwrap_or_else(|| std::path::PathBuf::from(".")) + } + + fn open_login_or_account_panel( + &self, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let logged_in = + tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); + if logged_in { + self.open_account_panel(chat_view, rt_handle); + } else { + chat_view.show_login_form(); + } + let _ = chat_state; + } + + fn open_account_panel(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { + let (info, devices, progress) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let info = crate::account::account_info().await; + let devices = crate::account::list_devices().await.unwrap_or_default(); + let progress = crate::account_sync::current_sync_progress().await; + (info, devices, progress) + }) + }); + match info { + Ok(info) => chat_view.show_account_panel(info, devices, progress), + Err(e) => { + chat_view.set_status(Some(format!("Failed to load account: {e}"))); + chat_view.show_login_form(); + } + } + } + + fn refresh_account_panel_live(&self, chat_view: &mut ChatView) { + if !chat_view.login_form_visible() { + return; + } + let progress = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(crate::account_sync::current_sync_progress()) + }); + let devices = if matches!( + progress.status, + crate::account_sync::SyncStatus::Syncing | crate::account_sync::SyncStatus::Done + ) { + tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(crate::account::list_devices()) + .ok() + }) + } else { + None + }; + chat_view.update_account_panel_progress(devices, progress); + } + + fn start_sync_and_show_account( + &self, + is_first_login: bool, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace = self.workspace_path_for_sync(chat_state); + crate::account_sync::start_auto_sync_background( + self.runtime.compatibility().clone(), + is_first_login, + workspace, + ); + self.open_account_panel(chat_view, rt_handle); + chat_state.add_system_message(if is_first_login { + "Sync started (use local / upload settings).".to_string() + } else { + "Sync started (use cloud / download settings).".to_string() + }); + } + + fn handle_login_form_action( + &self, + action: LoginFormAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + match action { + LoginFormAction::Submit(creds) => { + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(crate::account::login_with_credentials( + &creds.relay_url, + &creds.username, + &creds.password, + )) + }); + match result { + Ok(login) => { + chat_state.add_system_message(login.status_message.clone()); + if login.has_cloud_settings { + chat_view.show_sync_choice_panel(&login.user_id, &login.relay_url); + } else { + self.start_sync_and_show_account( + true, chat_view, chat_state, rt_handle, + ); + } + } + Err(e) => { + chat_view.login_form_set_error(format!("Login failed: {e}")); + } + } + } + LoginFormAction::SyncUseLocal => { + self.start_sync_and_show_account(true, chat_view, chat_state, rt_handle); + } + LoginFormAction::SyncUseCloud => { + self.start_sync_and_show_account(false, chat_view, chat_state, rt_handle); + } + LoginFormAction::SyncCancel => { + let _ = + tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())); + chat_view.show_login_form(); + chat_state.add_system_message("Sync cancelled; logged out.".to_string()); + } + LoginFormAction::Logout => { + match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { + Ok(()) => { + chat_view.show_login_form(); + chat_state.add_system_message("Logged out.".to_string()); + } + Err(e) => { + chat_view.login_form_set_error(format!("Logout failed: {e}")); + } + } + } + LoginFormAction::Cancel => { + chat_view.set_status(Some("Account panel closed".to_string())); + } + LoginFormAction::None => {} + } + Ok(None) + } + + /// Check if any popup is currently visible + fn any_popup_visible(&self, chat_view: &ChatView) -> bool { + chat_view.command_palette_visible() + || chat_view.model_selector_visible() + || chat_view.agent_selector_visible() + || chat_view.session_selector_visible() + || chat_view.skill_selector_visible() + || chat_view.subagent_selector_visible() + || chat_view.mcp_selector_visible() + || chat_view.mcp_add_dialog_visible() + || chat_view.provider_selector_visible() + || chat_view.model_config_form_visible() + || chat_view.login_form_visible() + || chat_view.theme_selector_visible() + || chat_view.info_popup_visible() + } + + /// Close all popups and clear the navigation stack + fn close_all_popups(&self, chat_view: &mut ChatView) { + // Cancel theme preview if active + if chat_view.theme_selector_visible() { + chat_view.cancel_theme_preview(); + } + chat_view.hide_command_palette(); + chat_view.hide_model_selector(); + chat_view.hide_agent_selector(); + chat_view.hide_session_selector(); + chat_view.hide_skill_selector(); + chat_view.hide_subagent_selector(); + chat_view.hide_mcp_selector(); + chat_view.hide_mcp_add_dialog(); + chat_view.hide_provider_selector(); + chat_view.hide_model_config_form(); + chat_view.hide_login_form(); + chat_view.hide_theme_selector(); + chat_view.dismiss_info_popup(); + chat_view.popup_stack.clear(); + } + + /// Navigate back to the previous popup in the stack, or close all if at the root + fn navigate_back(&self, chat_view: &mut ChatView) { + // Pop the current popup from the stack and hide it + if let Some(current) = chat_view.popup_stack.pop() { + // Hide the current popup + match current { + crate::ui::chat::PopupType::CommandPalette => chat_view.hide_command_palette(), + crate::ui::chat::PopupType::ModelSelector => chat_view.hide_model_selector(), + crate::ui::chat::PopupType::AgentSelector => chat_view.hide_agent_selector(), + crate::ui::chat::PopupType::SessionSelector => chat_view.hide_session_selector(), + crate::ui::chat::PopupType::SkillSelector => chat_view.hide_skill_selector(), + crate::ui::chat::PopupType::SubagentSelector => chat_view.hide_subagent_selector(), + crate::ui::chat::PopupType::McpSelector => chat_view.hide_mcp_selector(), + crate::ui::chat::PopupType::McpAddDialog => chat_view.hide_mcp_add_dialog(), + crate::ui::chat::PopupType::ProviderSelector => chat_view.hide_provider_selector(), + crate::ui::chat::PopupType::ModelConfigForm => chat_view.hide_model_config_form(), + crate::ui::chat::PopupType::LoginForm => chat_view.hide_login_form(), + crate::ui::chat::PopupType::ThemeSelector => { + chat_view.hide_theme_selector(); + chat_view.cancel_theme_preview(); + } + crate::ui::chat::PopupType::InfoPopup => chat_view.dismiss_info_popup(), + } + + // If there's a previous popup in the stack, re-show it + if let Some(previous) = chat_view.popup_stack.peek() { + match previous { + crate::ui::chat::PopupType::CommandPalette => { + chat_view.reshow_command_palette() + } + crate::ui::chat::PopupType::ModelSelector => chat_view.reshow_model_selector(), + crate::ui::chat::PopupType::AgentSelector => chat_view.reshow_agent_selector(), + crate::ui::chat::PopupType::SessionSelector => { + chat_view.reshow_session_selector() + } + crate::ui::chat::PopupType::SkillSelector => chat_view.reshow_skill_selector(), + crate::ui::chat::PopupType::SubagentSelector => { + chat_view.reshow_subagent_selector() + } + crate::ui::chat::PopupType::McpSelector => chat_view.reshow_mcp_selector(), + crate::ui::chat::PopupType::McpAddDialog => chat_view.reshow_mcp_add_dialog(), + crate::ui::chat::PopupType::ProviderSelector => { + chat_view.reshow_provider_selector() + } + crate::ui::chat::PopupType::ModelConfigForm => { + chat_view.reshow_model_config_form() + } + crate::ui::chat::PopupType::LoginForm => chat_view.reshow_login_form(), + crate::ui::chat::PopupType::ThemeSelector => chat_view.reshow_theme_selector(), + crate::ui::chat::PopupType::InfoPopup => {} + } + } + } + } +} diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs new file mode 100644 index 0000000000..8cd071fe7c --- /dev/null +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -0,0 +1,399 @@ +impl ChatMode { + fn show_skill_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_skill_menu(); + } + + /// Re-scan skill directories from disk and rebuild the registry cache. + /// + /// Mirrors Claude Code 2.1.152 `/reload-skills`. Safe to call at any + /// time — does not require `is_processing` to be false because the + /// registry swap is atomic and a held `SkillInfo` reference is not + /// kept across the call. + fn reload_skills_from_disk( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = SkillRegistry::global(); + let workspace = self.agent.workspace_path_buf(); + let outcome = tokio::task::block_in_place(|| { + // refresh() is the global re-scan entry point; the workspace + // arg of refresh_for_workspace is currently a no-op upstream, + // so we call refresh() directly and re-resolve the workspace + // count afterwards. + rt_handle.block_on(async { + registry.refresh().await; + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), None) + .await + }) + }); + + let count = outcome.len(); + chat_state.add_system_message(format!("Reloaded {} skill(s) from disk.", count)); + chat_view.set_status(Some(format!("Skills reloaded ({} available)", count))); + } + + fn show_available_skill_list( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(async { + let registry = SkillRegistry::global(); + registry + .get_resolved_skills_for_workspace(Some(workspace.as_path()), Some(&agent_type)) + .await + }) + }); + + if skills.is_empty() { + chat_state.add_system_message(format!( + "No enabled skills found for agent mode '{}'. Add skills in .bitfun/skills/, .cursor/skills/, or ~/.cursor/skills/, or enable built-in skills for this mode.", + self.agent_type + )); + return; + } + + let skill_items: Vec = + skills.into_iter().map(Self::skill_item_from_info).collect(); + + if skill_items.is_empty() { + chat_state.add_system_message("No skills found.".to_string()); + return; + } + + chat_view.show_skill_list(skill_items); + } + + fn show_skill_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let skills = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(async { + let registry = SkillRegistry::global(); + registry + .get_mode_skill_infos_for_workspace(Some(workspace.as_path()), &agent_type) + .await + }) + }); + + let skill_items: Vec = skills + .into_iter() + .map(Self::skill_item_from_mode_info) + .collect(); + + if skill_items.is_empty() { + chat_state.add_system_message("No skills found.".to_string()); + return; + } + + chat_view.show_skill_config(skill_items); + } + + fn handle_skill_selector_action( + &self, + action: SkillSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SkillSelectorAction::ListSkills => { + self.show_available_skill_list(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::ConfigureSkills => { + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + SkillSelectorAction::Execute(selected) => { + chat_view.hide_skill_selector(); + self.apply_skill_selection(&selected, chat_view); + } + SkillSelectorAction::Toggle(selected) => { + self.set_skill_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_skill_config_selector(chat_view, chat_state, rt_handle); + } + } + } + + /// Apply skill selection: fill input box with execution command + fn apply_skill_selection(&self, selected: &SkillItem, chat_view: &mut ChatView) { + chat_view.set_input(&format!("Execute the {} skill.", selected.name)); + } + + fn set_skill_enabled( + &self, + selected: &SkillItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let skill = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + match skill.level.as_str() { + "user" => { + set_user_mode_skill_state( + &mode_id, + &skill.key, + enabled, + skill.default_enabled, + ) + .await + .map_err(|error| error.to_string())?; + } + "project" => { + let mut document = load_project_mode_skills_document_local(&workspace) + .await + .map_err(|error| error.to_string())?; + set_mode_skill_disabled_in_document( + &mut document, + &mode_id, + &skill.key, + !enabled, + ) + .map_err(|error| error.to_string())?; + save_project_mode_skills_document_local(&workspace, &document) + .await + .map_err(|error| error.to_string())?; + } + other => { + return Err(format!("Unsupported skill level '{}'", other)); + } + } + + Ok(()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Skill '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update skill '{}': {}", + selected.name, error + )), + } + } + + fn skill_item_from_info(info: SkillInfo) -> SkillItem { + SkillItem { + key: info.key, + name: info.name, + description: info.description, + level: info.level.as_str().to_string(), + enabled: true, + selected_for_runtime: true, + default_enabled: true, + is_shadowed: info.is_shadowed, + } + } + + fn skill_item_from_mode_info(info: ModeSkillInfo) -> SkillItem { + SkillItem { + key: info.skill.key, + name: info.skill.name, + description: info.skill.description, + level: info.skill.level.as_str().to_string(), + enabled: info.effective_enabled, + selected_for_runtime: info.selected_for_runtime, + default_enabled: info.default_enabled, + is_shadowed: info.skill.is_shadowed, + } + } + + /// Show subagent list/configuration menu. + fn show_subagent_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + _rt_handle: &tokio::runtime::Handle, + ) { + chat_view.show_subagent_menu(); + } + + fn show_available_subagent_list( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + })) + }); + + if subagents.is_empty() { + chat_state.add_system_message(format!( + "No enabled subagents found for agent mode '{}'.", + self.agent_type + )); + return; + } + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { + chat_state.add_system_message("No subagents found.".to_string()); + return; + } + + chat_view.show_subagent_list(subagent_items); + } + + fn show_subagent_config_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let subagents = tokio::task::block_in_place(|| { + let workspace = self.agent.workspace_path_buf(); + let agent_type = self.agent_type.clone(); + rt_handle.block_on(registry.get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some(&agent_type), + workspace_root: Some(workspace.as_path()), + list_scope: SubagentListScope::RegistryManagement, + include_disabled: true, + })) + }); + + let subagent_items: Vec = subagents + .into_iter() + .map(Self::subagent_item_from_info) + .collect(); + + if subagent_items.is_empty() { + chat_state.add_system_message("No subagents found.".to_string()); + return; + } + + chat_view.show_subagent_config(subagent_items); + } + + fn handle_subagent_selector_action( + &self, + action: SubagentSelectorAction, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + match action { + SubagentSelectorAction::ListSubagents => { + self.show_available_subagent_list(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::ConfigureSubagents => { + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + SubagentSelectorAction::Launch(selected) => { + chat_view.hide_subagent_selector(); + self.apply_subagent_selection(&selected, chat_view); + } + SubagentSelectorAction::Toggle(selected) => { + self.set_subagent_enabled(&selected, !selected.enabled, chat_state, rt_handle); + self.show_subagent_config_selector(chat_view, chat_state, rt_handle); + } + } + } + + /// Apply subagent selection: fill input box with launch command + fn apply_subagent_selection(&self, selected: &SubagentItem, chat_view: &mut ChatView) { + chat_view.set_input(&format!( + "Launch subagent {} to finish task: ", + selected.name + )); + } + + fn set_subagent_enabled( + &self, + selected: &SubagentItem, + enabled: bool, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let registry = get_agent_registry(); + let workspace = self.agent.workspace_path_buf(); + let mode_id = self.agent_type.clone(); + let subagent = selected.clone(); + + let result: Result<(), String> = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + registry + .update_subagent_override( + &mode_id, + &subagent.id, + enabled, + Some(workspace.as_path()), + ) + .await + .map_err(|error| error.to_string()) + }) + }); + + match result { + Ok(()) => chat_state.add_system_message(format!( + "Subagent '{}' {} for mode '{}'.", + selected.name, + if enabled { "enabled" } else { "disabled" }, + self.agent_type + )), + Err(error) => chat_state.add_system_message(format!( + "Failed to update subagent '{}': {}", + selected.name, error + )), + } + } + + fn subagent_item_from_info(info: AgentInfo) -> SubagentItem { + let source = match info.subagent_source { + Some(SubAgentSource::Builtin) => "builtin", + Some(SubAgentSource::Project) => "project", + Some(SubAgentSource::User) => "user", + None => "builtin", + } + .to_string(); + + SubagentItem { + key: info.key, + id: info.id, + name: info.name, + description: info.description, + source, + enabled: info.effective_enabled, + } + } + +} diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs new file mode 100644 index 0000000000..95993bd365 --- /dev/null +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -0,0 +1,744 @@ +impl ChatMode { + /// Handle command palette action + fn handle_palette_action( + &mut self, + action_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + // Hide command palette but keep it in stack for back navigation + // (unless the action switches away or exits) + let keep_in_stack = matches!(action_id, "new_session" | "exit"); + if !keep_in_stack { + chat_view.hide_command_palette(); + } + self.handle_action_id(action_id, chat_view, chat_state, rt_handle) + } + + fn handle_action_id( + &mut self, + action_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if let Some(external) = self.external_command_projection_for_action(action_id) { + return self.select_and_handle_external_command( + &external, "", chat_view, chat_state, rt_handle, + ); + } + let Some(action) = action_by_id(action_id, ActionContext::Chat) else { + 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, + ); + } + self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ) + } + + /// Handle shortcut commands + fn handle_command( + &mut self, + command: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Ok(None); + } + + let token = parts[0]; + let (qualifier, command_name) = parse_command_token(token); + let arguments = command + .get(token.len()..) + .map(str::trim_start) + .unwrap_or(""); + if let Some(candidate) = self.external_conflict_projection_for_alias(token) { + return self.select_and_handle_external_command( + &candidate, arguments, chat_view, chat_state, rt_handle, + ); + } + let builtin_alias = format!("/{command_name}"); + let builtin_action = action_for_alias(&builtin_alias, ActionContext::Chat); + let mut external = self.external_command_projection(command_name); + let authoritative_preferences = tokio::task::block_in_place(|| { + rt_handle + .block_on(external_source_conflict_choices()) + .map(Into::into) + }); + if let Ok(authoritative_preferences) = authoritative_preferences { + if authoritative_preferences != self.external_conflict_preferences() { + self.replace_external_conflict_preferences(authoritative_preferences); + external = self.external_command_projection(command_name); + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + } + } + let builtin_reconfirmation = builtin_action.and_then(|action| { + builtin_command_reconfirmation( + action.id, + action.name, + &self.external_conflict_preferences(), + ) + }); + if let Some(collision) = external + .as_ref() + .and_then(|command| command.native_collision.as_ref()) + .cloned() + { + if qualifier == CommandQualifier::External { + self.remember_native_command_choice( + &collision, + &collision.external_candidate_id, + chat_view, + rt_handle, + ); + } else if qualifier == CommandQualifier::Builtin { + self.remember_native_command_choice( + &collision, + &collision.native_candidate_id, + chat_view, + rt_handle, + ); + } + } else if qualifier == CommandQualifier::Builtin { + if let Some(reconfirmation) = builtin_reconfirmation + .as_ref() + .filter(|reconfirmation| !reconfirmation.confirmed) + { + self.remember_command_choice( + &reconfirmation.conflict_key, + &reconfirmation.candidate_id, + vec![reconfirmation.candidate_id.clone()], + chat_view, + rt_handle, + ); + } else if let Some(collision) = builtin_action + .and_then(|action| self.native_command_collision_for_action(action.id)) + { + let native_candidate_id = collision.native_candidate_id.clone(); + self.remember_native_command_choice( + &collision, + &native_candidate_id, + chat_view, + rt_handle, + ); + } + } + let builtin_reconfirmation_required = external.is_none() + && builtin_reconfirmation + .as_ref() + .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) + && qualifier != CommandQualifier::External + && (qualifier == CommandQualifier::Builtin + || (external.is_none() + && unresolved_candidates.is_empty() + && !builtin_reconfirmation_required)); + if can_route_external_tool_review { + self.handle_external_tool_review(arguments, chat_view, chat_state, rt_handle); + return Ok(None); + } + let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { + candidate + .native_collision + .as_ref() + .is_some_and(|collision| { + collision.selected_candidate_id.as_deref() + == Some(collision.native_candidate_id.as_str()) + }) + }); + if external.is_none() + && qualifier != CommandQualifier::Builtin + && !unresolved_candidates.is_empty() + && !native_choice_is_active + { + let mut choices = unresolved_candidates + .iter() + .map(|candidate| { + if candidate.restricted { + format!("{} (restricted)", candidate.invocation_alias) + } else { + candidate.invocation_alias.clone() + } + }) + .collect::>(); + if builtin_action.is_some() { + choices.insert(0, format!("/builtin:{command_name}")); + } + chat_state.add_system_message(format!( + "Command /{command_name} is provided by multiple sources. Choose one once: {}. The choice is remembered until a participant changes.", + choices.join(", ") + )); + return Ok(None); + } + let discovery_pending = self + .external_source_snapshot + .as_ref() + .is_some_and(|snapshot| snapshot.discovery_pending); + match command_route( + qualifier, + builtin_action.is_some(), + external.as_ref(), + discovery_pending, + builtin_reconfirmation_required, + ) { + CommandRoute::Builtin => { + let action = builtin_action.expect("route requires an available built-in action"); + self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ) + } + CommandRoute::External => match self.handle_external_command( + command_name, + arguments, + external.as_ref(), + chat_view, + chat_state, + rt_handle, + ) { + 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] + )); + Ok(None) + } + Err(error) => Err(error), + }, + CommandRoute::AskForCollisionChoice => { + if builtin_reconfirmation_required { + chat_state.add_system_message(format!( + "The previous external candidate for /{command_name} changed or was removed. Use /builtin:{command_name} once to confirm the remaining BitFun command." + )); + } else { + chat_state.add_system_message(format!( + "Command /{command_name} is provided by BitFun and an external source. Choose /builtin:{command_name} or /external:{command_name}; the choice is remembered until the external command changes." + )); + } + Ok(None) + } + CommandRoute::WaitForDiscovery => { + let explicit = if builtin_action.is_some() { + format!(" Use /builtin:{command_name} to run the BitFun command now.") + } else { + String::new() + }; + chat_state.add_system_message(format!( + "BitFun is still checking compatible external commands.{explicit}" + )); + Ok(None) + } + CommandRoute::UnknownBuiltin => { + chat_state.add_system_message(format!( + "Unknown built-in command: /builtin:{command_name}\nUse /help or type / to see available commands" + )); + Ok(None) + } + } + } + + fn external_command_projection(&self, command_name: &str) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| { + command.provider_conflict_key.is_none() + && command.command_name.eq_ignore_ascii_case(command_name) + }) + } + + fn external_command_projection_for_action( + &self, + action_id: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| command.action_id == action_id) + } + + fn external_conflict_projection_for_alias( + &self, + token: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .find(|command| { + command.provider_conflict_key.is_some() + && command.invocation_alias.eq_ignore_ascii_case(token) + }) + } + + fn external_conflict_projections(&self, command_name: &str) -> Vec { + self.external_source_snapshot + .as_ref() + .map(|snapshot| { + external_command_projections(snapshot, &self.external_source_conflict_choices) + .into_iter() + .filter(|command| { + command.provider_conflict_key.is_some() + && command.command_name.eq_ignore_ascii_case(command_name) + }) + .collect() + }) + .unwrap_or_default() + } + + fn native_command_collision_for_action( + &self, + action_id: &str, + ) -> Option { + external_command_projections( + self.external_source_snapshot.as_ref()?, + &self.external_source_conflict_choices, + ) + .into_iter() + .filter_map(|command| command.native_collision) + .find(|collision| collision.native_action_id == action_id) + } + + fn remember_native_command_choice( + &mut self, + collision: &NativeCommandCollisionProjection, + candidate_id: &str, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + self.remember_command_choice( + &collision.conflict_key, + candidate_id, + vec![ + collision.native_candidate_id.clone(), + collision.external_candidate_id.clone(), + ], + chat_view, + rt_handle, + ); + } + + fn remember_command_choice( + &mut self, + conflict_key: &str, + candidate_id: &str, + participants: Vec, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + let persisted = tokio::task::block_in_place(|| { + rt_handle.block_on(remember_external_source_conflict_choice( + conflict_key, + candidate_id, + participants.clone(), + )) + }); + match persisted { + Ok(preferences) => self.replace_external_conflict_preferences(preferences.into()), + Err(error) => { + tracing::warn!( + "Failed to persist external command conflict choice: {}", + error + ); + chat_view.set_status(Some( + "The command choice could not be saved; this explicit command will run once" + .to_string(), + )); + } + } + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + } + + fn select_and_handle_external_command( + &mut self, + projection: &ExternalCommandProjection, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if projection.restricted { + chat_state.add_system_message(format!( + "External command {} is currently restricted and cannot be selected.", + projection.invocation_alias + )); + return Ok(None); + } + if let Some(provider_conflict_key) = &projection.provider_conflict_key { + let workspace = self.agent.workspace_path_buf(); + let snapshot = tokio::task::block_in_place(|| { + rt_handle.block_on(set_external_prompt_command_conflict_choice( + Some(&workspace), + provider_conflict_key, + &projection.candidate_id, + )) + }); + let snapshot = match snapshot { + Ok(snapshot) => snapshot, + Err(error) => { + chat_state.add_system_message(format!( + "Could not select {}: {error}", + projection.invocation_alias + )); + return Ok(None); + } + }; + if let Some(collision) = &projection.native_collision { + self.remember_native_command_choice( + collision, + &projection.candidate_id, + chat_view, + rt_handle, + ); + } + self.external_source_snapshot = Some(snapshot); + let Some(active) = self.external_command_projection(&projection.command_name) else { + chat_state.add_system_message(format!( + "Selected external command /{} is no longer available; refresh and choose again.", + projection.command_name + )); + return Ok(None); + }; + if let Some(collision) = &active.native_collision { + self.remember_native_command_choice( + collision, + &active.candidate_id, + chat_view, + rt_handle, + ); + } + if let Some(snapshot) = &self.external_source_snapshot { + self.update_external_source_view(chat_view, snapshot); + } + return self.handle_external_command( + &projection.command_name, + arguments, + Some(&active), + chat_view, + chat_state, + rt_handle, + ); + } + if let Some(collision) = &projection.native_collision { + self.remember_native_command_choice( + collision, + &projection.candidate_id, + chat_view, + rt_handle, + ); + } + self.handle_external_command( + &projection.command_name, + arguments, + Some(projection), + chat_view, + chat_state, + rt_handle, + ) + } + + fn handle_external_command( + &mut self, + command_name: &str, + arguments: &str, + expected: Option<&ExternalCommandProjection>, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if chat_state.is_processing { + chat_view.set_status(Some( + "External prompt commands are unavailable while a turn is processing".to_string(), + )); + return Ok(None); + } + let workspace = self.agent.workspace_path_buf(); + let expanded = tokio::task::block_in_place(|| { + rt_handle.block_on(expand_external_prompt_command( + Some(&workspace), + command_name, + arguments, + expected.map(|command| command.candidate_id.as_str()), + expected.map(|command| command.content_version.as_str()), + )) + }); + match expanded { + Ok(expanded) => { + self.send_message_to_agent(expanded.content, chat_view, chat_state, rt_handle); + Ok(None) + } + Err(error) if error.contains("command not found") => Err(anyhow!(error)), + Err(error) => { + chat_state.add_system_message(format!( + "External command /{command_name} is unavailable: {error}" + )); + Ok(None) + } + } + } + + fn dispatch_action( + &mut self, + action: &'static ActionSpec, + state: ActionState, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if !action.available(state) { + chat_view.set_status(Some(action.unavailable_message(state))); + return Ok(None); + } + + match action.handler { + ActionHandler::Help => { + chat_view.show_info_popup(self.keymap.help_text(state)); + } + ActionHandler::ClearConversation => { + if chat_state.is_processing { + self.cancel_active_turn(chat_view, rt_handle); + } + chat_state.clear_messages(); + chat_view.clear_screen(); + chat_view.set_status(Some("Conversation cleared".to_string())); + } + ActionHandler::OpenAgentSelector => { + self.show_agent_selector(chat_view, chat_state, rt_handle); + } + ActionHandler::SwitchAgent => { + self.cycle_agent(chat_view, chat_state, rt_handle); + } + ActionHandler::SwitchAgentReverse => { + self.cycle_agent_reverse(chat_view, chat_state, rt_handle); + } + ActionHandler::SelectModel => { + self.show_model_selector(chat_view, chat_state, rt_handle); + } + ActionHandler::SelectTheme => { + let themes = self.list_available_themes(); + chat_view.begin_theme_preview(); + chat_view.show_theme_selector(themes, Some(self.config.ui.theme_id.clone())); + chat_view.set_status(Some( + "Theme selector: ↑↓ preview, Enter apply, Esc cancel".to_string(), + )); + } + ActionHandler::AddModel => chat_view.show_provider_selector(), + ActionHandler::NewSession => { + return Ok(Some(ChatExitReason::NewSession)); + } + ActionHandler::Sessions => { + self.show_session_selector(chat_view, chat_state, rt_handle); + } + ActionHandler::Skills => { + self.show_skill_selector(chat_view, chat_state, rt_handle); + } + 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 => { + self.handle_external_tool_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( + "ACP setup added to the conversation. You can keep typing.".to_string(), + )); + } + ActionHandler::Init => match crate::prompts::get_cli_prompt("init") { + Some(prompt) => { + self.send_message_to_agent(prompt.to_string(), chat_view, chat_state, rt_handle) + } + None => chat_state.add_system_message( + "Init prompt not found. Please create prompts/init.md in the CLI crate." + .to_string(), + ), + }, + ActionHandler::History => { + chat_state.add_system_message(format!( + "Current session statistics:\n\ + • Messages: {}\n\ + • Tool calls: {}\n\ + • Tokens: {}", + chat_state.metadata.message_count, + chat_state.metadata.tool_calls, + chat_state.metadata.total_tokens + )); + } + ActionHandler::Usage => self.show_usage_report(chat_view, chat_state, rt_handle), + ActionHandler::Exit => { + if chat_state.is_processing { + self.cancel_active_turn(chat_view, rt_handle); + } + return Ok(Some(ChatExitReason::Quit)); + } + ActionHandler::Login => { + self.close_all_popups(chat_view); + self.open_login_or_account_panel(chat_view, chat_state, rt_handle); + } + ActionHandler::Logout => self.logout(chat_state, rt_handle), + ActionHandler::OpenPalette => chat_view.show_command_palette(state), + ActionHandler::SubmitInput => { + return self.submit_input(chat_view, chat_state, rt_handle); + } + ActionHandler::Interrupt => self.cancel_active_turn(chat_view, rt_handle), + ActionHandler::ClosePopups => self.close_all_popups(chat_view), + ActionHandler::NavigateBack => self.navigate_back(chat_view), + ActionHandler::InsertNewline => chat_view.handle_newline(), + ActionHandler::Paste => self.paste_clipboard(chat_view), + ActionHandler::ToggleFocusedTool => { + chat_view.toggle_focused_tool_expand(chat_state); + } + ActionHandler::PreviousTool => { + chat_view.cycle_block_tool_focus_prev(chat_state); + } + ActionHandler::NextTool => { + chat_view.cycle_block_tool_focus_next(chat_state); + } + ActionHandler::HistoryPrevious => { + if chat_view.command_menu_visible() { + chat_view.command_menu_up(); + } else { + chat_view.history_prev(); + } + } + ActionHandler::HistoryNext => { + if chat_view.command_menu_visible() { + chat_view.command_menu_down(); + } else { + chat_view.history_next(); + } + } + ActionHandler::JumpTop => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_to_top(total); + chat_view.set_status(Some("Jumped to conversation top".to_string())); + } + ActionHandler::JumpBottom => { + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("Jumped to conversation bottom".to_string())); + } + ActionHandler::ClearInput => chat_view.clear_input(), + ActionHandler::ToggleBrowse => { + chat_view.toggle_browse_mode(); + let status = if chat_view.browse_mode { + "Entered browse mode, use PageUp/PageDown or mouse wheel to scroll conversation" + } else { + "Exited browse mode" + }; + chat_view.set_status(Some(status.to_string())); + } + ActionHandler::ScrollUp => { + let total = chat_view.count_message_lines(chat_state); + chat_view.scroll_up(10, total); + } + ActionHandler::ScrollDown => chat_view.scroll_down(10), + } + Ok(None) + } + + fn submit_input( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if let Some(action_id) = chat_view.apply_command_menu_selection() { + return self.handle_action_id(&action_id, chat_view, chat_state, rt_handle); + } + + if chat_state.is_processing { + let trimmed = chat_view.input_text().trim(); + if trimmed.starts_with('/') { + if let Some(input) = chat_view.send_input() { + return self.handle_command(&input, chat_view, chat_state, rt_handle); + } + } else if !trimmed.is_empty() { + chat_view.set_status(Some( + "Currently processing. Type a /command, or use the interrupt shortcut." + .to_string(), + )); + } + return Ok(None); + } + + if let Some(input) = chat_view.send_input() { + tracing::info!("User input: {}", input); + if input.starts_with('/') { + return self.handle_command(&input, chat_view, chat_state, rt_handle); + } + self.send_message_to_agent(input, chat_view, chat_state, rt_handle); + } + Ok(None) + } + + fn cancel_active_turn(&self, chat_view: &mut ChatView, rt_handle: &tokio::runtime::Handle) { + tracing::info!("User requested cancellation"); + let agent = self.agent.clone(); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(error) = agent.cancel_current_turn().await { + tracing::error!("Failed to cancel turn: {}", error); + } + }) + }); + chat_view.set_status(Some("Cancelling...".to_string())); + } + + fn paste_clipboard(&self, chat_view: &mut ChatView) { + if let Ok(text) = Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { + chat_view.insert_paste(&text); + } + } + +} diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs new file mode 100644 index 0000000000..90b17a1987 --- /dev/null +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -0,0 +1,567 @@ +// Pure projections and review text derived from the external-source catalog. +fn native_command_conflict_key<'a>( + execution_domain_id: &str, + command_name: &str, + candidates: impl IntoIterator, +) -> String { + format!( + "native:{}", + prompt_command_conflict_key(execution_domain_id, command_name, candidates) + ) +} + +fn external_command_projections( + snapshot: &ExternalSourceCatalogSnapshot, + conflict_choices: &BTreeMap, +) -> Vec { + let built_in_actions = slash_actions(ActionState::chat(false, false)); + let mut projections = snapshot + .commands + .iter() + .map(|entry| { + let ecosystem = snapshot + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source) + .map(|source| source.record.ecosystem_id.as_str()) + .unwrap_or("external"); + let restricted = !matches!( + entry.definition.availability, + PromptCommandAvailability::Available + ); + let native_collision = built_in_actions.iter().find_map(|action| { + if !action + .name + .trim_start_matches('/') + .eq_ignore_ascii_case(&entry.definition.name) + { + return None; + } + let source = snapshot + .sources + .iter() + .find(|source| source.record.key == entry.definition.id.source)?; + let native_candidate_id = format!("bitfun.cli:{}", action.id); + let external_candidate_id = entry.definition.id.stable_key(); + let conflict_key = native_command_conflict_key( + source.record.execution_domain_id.as_str(), + &entry.definition.name, + [ + (native_candidate_id.as_str(), env!("CARGO_PKG_VERSION")), + ( + external_candidate_id.as_str(), + entry.definition.content_version.as_str(), + ), + ], + ); + Some(NativeCommandCollisionProjection { + native_action_id: action.id.to_string(), + native_candidate_id, + external_candidate_id, + selected_candidate_id: conflict_choices.get(&conflict_key).cloned(), + conflict_key, + }) + }); + ExternalCommandProjection { + action_id: format!("external-command:{}", entry.definition.name), + command_name: entry.definition.name.clone(), + invocation_alias: format!("/{}", entry.definition.name), + candidate_id: entry.definition.id.stable_key(), + content_version: entry.definition.content_version.clone(), + description: format!("{} · {}", entry.definition.description, ecosystem), + restricted, + provider_conflict_key: None, + native_collision, + } + }) + .collect::>(); + + for conflict in snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + { + let built_in = built_in_actions.iter().find(|action| { + action + .name + .trim_start_matches('/') + .eq_ignore_ascii_case(&conflict.command_name) + }); + let native_group = built_in.and_then(|action| { + let execution_domain = conflict.candidates.iter().find_map(|candidate| { + snapshot + .sources + .iter() + .find(|source| source.record.key == candidate.source) + .map(|source| source.record.execution_domain_id.as_str()) + })?; + let native_candidate_id = format!("bitfun.cli:{}", action.id); + let mut candidates = conflict + .candidates + .iter() + .map(|candidate| { + ( + candidate.candidate_id.as_str(), + candidate.content_version.as_str(), + ) + }) + .collect::>(); + candidates.push((native_candidate_id.as_str(), env!("CARGO_PKG_VERSION"))); + let conflict_key = + native_command_conflict_key(execution_domain, &conflict.command_name, candidates); + Some((action.id.to_string(), native_candidate_id, conflict_key)) + }); + projections.extend(conflict.candidates.iter().map(|candidate| { + let native_collision = native_group.as_ref().map( + |(native_action_id, native_candidate_id, conflict_key)| { + NativeCommandCollisionProjection { + native_action_id: native_action_id.clone(), + native_candidate_id: native_candidate_id.clone(), + external_candidate_id: candidate.candidate_id.clone(), + selected_candidate_id: conflict_choices.get(conflict_key).cloned(), + conflict_key: conflict_key.clone(), + } + }, + ); + ExternalCommandProjection { + action_id: format!("external-command-candidate:{}", candidate.candidate_id), + command_name: conflict.command_name.clone(), + invocation_alias: format!( + "/external:{}:{}", + candidate.source.provider_id, conflict.command_name + ), + candidate_id: candidate.candidate_id.clone(), + content_version: candidate.content_version.clone(), + description: format!( + "{} · {} · {}", + candidate.command_description, + candidate.source_display_name, + candidate.ecosystem_id + ), + restricted: !matches!(candidate.availability, PromptCommandAvailability::Available), + provider_conflict_key: Some(conflict.conflict_key.clone()), + native_collision, + } + })); + } + projections +} + +fn external_command_counts(snapshot: &ExternalSourceCatalogSnapshot) -> (usize, usize) { + snapshot + .commands + .iter() + .fold((0, 0), |(available, restricted), entry| { + if matches!( + entry.definition.availability, + PromptCommandAvailability::Available + ) { + (available + 1, restricted) + } else { + (available, restricted + 1) + } + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalToolReviewAction { + Show, + Refresh, + Decide { + approval_key: String, + decision_key: String, + approved: bool, + }, + Choose { + conflict_key: String, + candidate_id: String, + }, +} + +struct ExternalToolMutationResult { + action: ExternalToolReviewAction, + result: std::result::Result, +} + +struct ExternalToolTargetSummary<'a> { + tools: Vec<&'a ExternalToolCatalogEntry>, +} + +impl<'a> ExternalToolTargetSummary<'a> { + fn first(&self) -> &'a ExternalToolCatalogEntry { + self.tools[0] + } + + fn activation(&self) -> &'a ExternalToolActivationState { + &self.first().activation + } + + fn names(&self) -> String { + let mut names = self + .tools + .iter() + .map(|tool| tool.definition.name.as_str()) + .collect::>(); + names.sort_unstable(); + names.dedup(); + names.join(", ") + } +} + +fn external_tool_target_summaries( + snapshot: &ExternalSourceCatalogSnapshot, +) -> Vec> { + let mut summaries: Vec> = Vec::new(); + for tool in &snapshot.tools { + if let Some(summary) = summaries + .iter_mut() + .find(|summary| summary.first().definition.id.target == tool.definition.id.target) + { + summary.tools.push(tool); + } else { + summaries.push(ExternalToolTargetSummary { tools: vec![tool] }); + } + } + summaries +} + +fn external_tool_activation_label(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => "approval required", + ExternalToolActivationState::Disabled => "disabled", + ExternalToolActivationState::Active => "active", + ExternalToolActivationState::Conflict => "name conflict", + ExternalToolActivationState::Unsupported { .. } => "unsupported", + ExternalToolActivationState::RuntimeUnavailable { .. } => "runtime unavailable", + ExternalToolActivationState::LoadFailed { .. } => "load failed", + _ => "unknown", + } +} + +fn external_tool_scope_label(scope: impl std::fmt::Debug) -> &'static str { + match format!("{scope:?}").as_str() { + "UserGlobal" => "user global", + "Project" => "project", + "WorkspaceLocal" => "workspace local", + "RemoteUser" => "remote user", + "RemoteProject" => "remote project", + _ => "unknown", + } +} + +fn external_tool_user_facing_reason(reason: &str) -> String { + reason + .replace("PR2 worker", "Tool process") + .replace("PR2", "This version") +} + +fn external_tool_reason(summary: &ExternalToolTargetSummary<'_>) -> Option { + match summary.activation() { + ExternalToolActivationState::Unsupported { reason } + | ExternalToolActivationState::RuntimeUnavailable { reason } + | ExternalToolActivationState::LoadFailed { reason } => { + Some(external_tool_user_facing_reason(reason)) + } + _ => None, + } +} + +fn external_tool_next_step(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => { + "Review access, then enable the target or keep it disabled." + } + ExternalToolActivationState::Disabled => { + "Enable the target after reviewing its source and access." + } + ExternalToolActivationState::Active => { + "No action is required. Disable the target to stop exposing it." + } + ExternalToolActivationState::Conflict => "Choose a provider below, or disable this target.", + ExternalToolActivationState::Unsupported { .. } => { + "Convert the module to the supported standalone JavaScript subset, then refresh." + } + ExternalToolActivationState::RuntimeUnavailable { .. } => { + "Restore the required runtime, then refresh." + } + ExternalToolActivationState::LoadFailed { .. } => { + "Refresh to retry. If it still fails, inspect or update the module, or disable this target." + } + _ => "Refresh to retrieve the current target state.", + } +} + +fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'static str { + match activation { + ExternalToolActivationState::ApprovalRequired => { + "The module needs approval before BitFun loads it." + } + ExternalToolActivationState::Disabled => "The target was disabled by user choice.", + ExternalToolActivationState::Active => { + "The module loaded successfully and is exposed in this execution domain." + } + ExternalToolActivationState::Conflict => "Another implementation uses the same tool name.", + ExternalToolActivationState::Unsupported { .. } => { + "The module uses unsupported syntax or behavior." + } + ExternalToolActivationState::RuntimeUnavailable { .. } => { + "The required JavaScript runtime is unavailable." + } + ExternalToolActivationState::LoadFailed { .. } => { + "The tool process could not load this module." + } + _ => "The current state is unavailable.", + } +} + +fn external_tool_can_enable(activation: &ExternalToolActivationState) -> bool { + matches!( + activation, + ExternalToolActivationState::ApprovalRequired | ExternalToolActivationState::Disabled + ) +} + +fn external_tool_can_disable(activation: &ExternalToolActivationState) -> bool { + matches!( + activation, + ExternalToolActivationState::ApprovalRequired + | ExternalToolActivationState::Active + | ExternalToolActivationState::Conflict + | ExternalToolActivationState::LoadFailed { .. } + ) +} + +fn external_tool_result_is_stale( + current: Option<&ExternalSourceCatalogSnapshot>, + incoming: &ExternalSourceCatalogSnapshot, +) -> bool { + current.is_some_and(|current| current.generation > incoming.generation) +} + +fn external_tool_pending_notice_key(snapshot: &ExternalSourceCatalogSnapshot) -> Option { + let mut decisions = snapshot + .tool_approval_requests + .iter() + .map(|request| format!("approval:{}", request.decision_key)) + .chain( + snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| format!("conflict:{}", conflict.conflict_key)), + ) + .collect::>(); + decisions.extend(snapshot.diagnostics.iter().filter_map(|diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning | ExternalSourceDiagnosticSeverity::Error + ) + .then(|| { + format!( + "diagnostic:{:?}:{}:{}:{}", + diagnostic.severity, + diagnostic.code, + diagnostic.message, + diagnostic + .source + .as_ref() + .map(|source| source.stable_key()) + .unwrap_or_default() + ) + }) + })); + if decisions.is_empty() { + return None; + } + decisions.sort_unstable(); + Some(decisions.join("\n")) +} + +fn external_tool_capability_label(capability: ExternalToolCapability) -> &'static str { + match capability { + ExternalToolCapability::FileSystem => "filesystem", + ExternalToolCapability::Network => "network", + ExternalToolCapability::Process => "process", + ExternalToolCapability::Environment => "environment variables", + _ => "other", + } +} + +fn external_tool_runtime_label(runtime: ExternalToolRuntimeKind) -> &'static str { + match runtime { + ExternalToolRuntimeKind::JavaScript => "JavaScript", + ExternalToolRuntimeKind::TypeScript => "TypeScript", + _ => "unknown runtime", + } +} + +fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { + let Some(snapshot) = snapshot else { + return "External tools\n\nTool discovery has not completed. Run /external-tools refresh and try again." + .to_string(); + }; + let mut lines = vec![ + "External tools".to_string(), + String::new(), + "No external code ran during discovery. Enabling a tool starts its external module with your user permissions and inherited environment variables; BitFun does not provide an OS sandbox or full descendant-process cleanup in this version." + .to_string(), + ]; + + if snapshot.discovery_pending { + lines.push(String::new()); + lines.push("Discovery is still running. Existing results remain usable.".to_string()); + } + + lines.push(String::new()); + lines.push("Targets".to_string()); + let targets = external_tool_target_summaries(snapshot); + if targets.is_empty() { + lines.push(" None".to_string()); + } else { + for (index, target) in targets.iter().enumerate() { + let tool = target.first(); + let source = snapshot + .sources + .iter() + .find(|source| source.record.key == tool.definition.id.target.source); + let capabilities = target + .tools + .iter() + .flat_map(|tool| tool.definition.capabilities.iter().copied()) + .collect::>() + .into_iter() + .map(external_tool_capability_label) + .collect::>() + .join(", "); + lines.push(format!( + " {}. {} - {}", + index + 1, + target.names(), + external_tool_activation_label(target.activation()) + )); + lines.push(format!( + " Source root: {}", + source + .map(|source| source.record.location.as_str()) + .unwrap_or("unknown") + )); + lines.push(" Module files:".to_string()); + let module_paths = target + .tools + .iter() + .map(|tool| tool.definition.module_path.as_str()) + .collect::>(); + for module_path in module_paths { + lines.push(format!(" - {module_path}")); + } + lines.push(format!( + " Scope: {}", + source + .map(|source| external_tool_scope_label(source.record.scope)) + .unwrap_or("unknown") + )); + lines.push(format!( + " Execution domain: {}", + source + .map(|source| source.record.execution_domain_id.as_str()) + .unwrap_or("unknown") + )); + lines.push(format!( + " Working directory: {}", + tool.definition.working_directory + )); + lines.push(format!( + " Runtime: {}", + external_tool_runtime_label(tool.definition.runtime_kind) + )); + lines.push(format!(" Access: {capabilities}")); + if let Some(reason) = external_tool_reason(target) { + lines.push(format!(" Reason: {reason}")); + } else { + lines.push(format!( + " Reason: {}", + external_tool_default_reason(target.activation()) + )); + } + lines.push(format!( + " Next step: {}", + external_tool_next_step(target.activation()) + )); + let mut commands = Vec::new(); + if external_tool_can_enable(target.activation()) { + commands.push(format!("/external-tools enable {}", index + 1)); + } + if external_tool_can_disable(target.activation()) { + commands.push(format!("/external-tools disable {}", index + 1)); + } + if !commands.is_empty() { + lines.push(format!(" Commands: {}", commands.join(" or "))); + } + } + } + + lines.push(String::new()); + lines.push("Name conflicts".to_string()); + let pending_conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .collect::>(); + if pending_conflicts.is_empty() { + lines.push(" None".to_string()); + } else { + for (conflict_index, conflict) in pending_conflicts.iter().enumerate() { + lines.push(format!( + " {}. Tool '{}' requires a provider choice:", + conflict_index + 1, + conflict.tool_name + )); + for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { + lines.push(format!( + " {}. {} ({}) - /external-tools choose {} {}", + candidate_index + 1, + candidate.display_name, + candidate.provider_id, + conflict_index + 1, + candidate_index + 1 + )); + } + lines.push( + " The choice applies to matching candidates in this execution domain." + .to_string(), + ); + } + } + + lines.push(String::new()); + lines.push("Diagnostics".to_string()); + if snapshot.diagnostics.is_empty() { + lines.push(" None".to_string()); + } else { + for diagnostic in &snapshot.diagnostics { + let severity = match diagnostic.severity { + ExternalSourceDiagnosticSeverity::Info => "info", + ExternalSourceDiagnosticSeverity::Warning => "warning", + ExternalSourceDiagnosticSeverity::Error => "error", + _ => "notice", + }; + let source = diagnostic + .source + .as_ref() + .map(|source| format!(" [{}]", source.stable_key())) + .unwrap_or_default(); + lines.push(format!( + " - {severity} [{}]{source}: {}", + diagnostic.code, + external_tool_user_facing_reason(&diagnostic.message) + )); + } + } + + lines.push(String::new()); + lines.push( + "Use /external-tools refresh after editing, upgrading, or removing external tools." + .to_string(), + ); + lines.join("\n") +} diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs new file mode 100644 index 0000000000..bbabe8a6de --- /dev/null +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -0,0 +1,525 @@ +fn parse_positive_index(value: Option<&str>, label: &str) -> Result { + let raw = value.ok_or_else(|| format!("missing {label}"))?; + let index = raw + .parse::() + .map_err(|_| format!("{label} must be a positive number"))?; + if index == 0 { + return Err(format!("{label} must be a positive number")); + } + Ok(index - 1) +} + +fn parse_external_tool_review_action( + arguments: &str, + current_snapshot: Option<&ExternalSourceCatalogSnapshot>, + reviewed_snapshot: Option<&ExternalSourceCatalogSnapshot>, +) -> Result { + let mut parts = arguments.split_whitespace(); + let Some(command) = parts.next() else { + return Ok(ExternalToolReviewAction::Show); + }; + if command.eq_ignore_ascii_case("refresh") { + if parts.next().is_some() { + return Err("usage: /external-tools refresh".to_string()); + } + return Ok(ExternalToolReviewAction::Refresh); + } + if command.eq_ignore_ascii_case("help") { + return Ok(ExternalToolReviewAction::Show); + } + // Numbered commands refer to the immutable catalog that produced the + // review popup. The backend still validates stable decision/conflict keys, + // 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(|| { + "tool discovery has not completed; run /external-tools refresh".to_string() + })?; + if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { + let index = parse_positive_index(parts.next(), "target number")?; + if parts.next().is_some() { + return Err(format!("usage: /external-tools {command} ")); + } + let targets = external_tool_target_summaries(snapshot); + let target = targets.get(index).ok_or_else(|| { + "that target is no longer available; reopen /external-tools".to_string() + })?; + let approved = command.eq_ignore_ascii_case("enable"); + let allowed = if approved { + external_tool_can_enable(target.activation()) + } else { + external_tool_can_disable(target.activation()) + }; + if !allowed { + return Err(format!( + "target {} is {}; reopen /external-tools for its next step", + index + 1, + external_tool_activation_label(target.activation()) + )); + } + let tool = target.first(); + return Ok(ExternalToolReviewAction::Decide { + approval_key: tool.approval_key.clone(), + decision_key: tool.decision_key.clone(), + approved, + }); + } + if command.eq_ignore_ascii_case("choose") { + let conflict_index = parse_positive_index(parts.next(), "conflict number")?; + let candidate_index = parse_positive_index(parts.next(), "candidate number")?; + if parts.next().is_some() { + return Err( + "usage: /external-tools choose ".to_string(), + ); + } + let conflict = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .nth(conflict_index) + .ok_or_else(|| { + "that conflict is no longer available; reopen /external-tools".to_string() + })?; + let candidate = conflict.candidates.get(candidate_index).ok_or_else(|| { + "that candidate is no longer available; reopen /external-tools".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()) +} + +fn external_tool_mutation_result_label( + action: &ExternalToolReviewAction, + snapshot: &ExternalSourceCatalogSnapshot, +) -> String { + match action { + ExternalToolReviewAction::Refresh => "External tools refreshed".to_string(), + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved: true, + } => { + let activations = snapshot + .tools + .iter() + .filter(|tool| { + tool.approval_key == *approval_key && tool.decision_key == *decision_key + }) + .map(|tool| &tool.activation) + .collect::>(); + if activations.is_empty() { + "External tool approval saved; reopen /external-tools to review the changed target" + .to_string() + } else if activations + .iter() + .any(|state| matches!(state, ExternalToolActivationState::LoadFailed { .. })) + { + "External tool approved, but loading failed".to_string() + } else if activations.iter().any(|state| { + matches!( + state, + ExternalToolActivationState::RuntimeUnavailable { .. } + ) + }) { + "External tool approved, but its runtime is unavailable".to_string() + } else if activations + .iter() + .any(|state| matches!(state, ExternalToolActivationState::Conflict)) + { + "External tool approved; choose a provider before every export is available" + .to_string() + } else if activations + .iter() + .all(|state| matches!(state, ExternalToolActivationState::Active)) + { + "External tool enabled".to_string() + } else { + "External tool approval saved; reopen /external-tools to review its current state" + .to_string() + } + } + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved: false, + } => { + let disabled = snapshot.tools.iter().any(|tool| { + tool.approval_key == *approval_key + && tool.decision_key == *decision_key + && matches!(tool.activation, ExternalToolActivationState::Disabled) + }); + if disabled { + "External tool disabled".to_string() + } else { + "External tool decision saved; reopen /external-tools to review the changed target" + .to_string() + } + } + ExternalToolReviewAction::Choose { + conflict_key, + candidate_id, + } => { + let selected = snapshot.tool_conflicts.iter().any(|conflict| { + conflict.conflict_key == *conflict_key + && conflict.selected_candidate_id.as_deref() == Some(candidate_id.as_str()) + }); + if selected { + "External tool provider selected".to_string() + } else { + "External tool candidates changed; reopen /external-tools before choosing" + .to_string() + } + } + ExternalToolReviewAction::Show => "External tools".to_string(), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BuiltinCommandReconfirmation { + conflict_key: String, + candidate_id: String, + confirmed: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct ExternalSourceConflictPreferences { + choices: BTreeMap, + lineage_current_keys: BTreeMap, + conflicted_candidate_ids: BTreeSet, +} + +impl + From<( + BTreeMap, + BTreeMap, + BTreeSet, + )> for ExternalSourceConflictPreferences +{ + fn from( + (choices, lineage_current_keys, conflicted_candidate_ids): ( + BTreeMap, + BTreeMap, + BTreeSet, + ), + ) -> Self { + Self { + choices, + lineage_current_keys, + conflicted_candidate_ids, + } + } +} + +fn builtin_command_reconfirmation( + action_id: &str, + action_name: &str, + preferences: &ExternalSourceConflictPreferences, +) -> Option { + let candidate_id = format!("bitfun.cli:{action_id}"); + let participated_in_conflict = preferences.conflicted_candidate_ids.contains(&candidate_id); + if !participated_in_conflict { + return None; + } + let command_name = action_name.trim_start_matches('/'); + let conflict_key = native_command_conflict_key( + "local-user", + command_name, + [(candidate_id.as_str(), env!("CARGO_PKG_VERSION"))], + ); + let confirmed = preferences.choices.get(&conflict_key) == Some(&candidate_id); + Some(BuiltinCommandReconfirmation { + conflict_key, + candidate_id, + confirmed, + }) +} + +fn builtin_reconfirmation_names( + preferences: &ExternalSourceConflictPreferences, +) -> BTreeSet { + slash_actions(ActionState::chat(false, false)) + .into_iter() + .filter(|action| { + builtin_command_reconfirmation(action.id, action.name, preferences) + .is_some_and(|reconfirmation| !reconfirmation.confirmed) + }) + .map(|action| action.name.trim_start_matches('/').to_ascii_lowercase()) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandQualifier { + Unqualified, + Builtin, + External, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandRoute { + Builtin, + External, + AskForCollisionChoice, + WaitForDiscovery, + UnknownBuiltin, +} + +fn parse_command_token(token: &str) -> (CommandQualifier, &str) { + let requested_name = token.trim_start_matches('/'); + let Some((qualifier, command_name)) = requested_name.split_once(':') else { + return (CommandQualifier::Unqualified, requested_name); + }; + if qualifier.eq_ignore_ascii_case("builtin") { + (CommandQualifier::Builtin, command_name) + } else if qualifier.eq_ignore_ascii_case("external") { + (CommandQualifier::External, command_name) + } else { + (CommandQualifier::Unqualified, requested_name) + } +} + +fn command_route( + qualifier: CommandQualifier, + has_builtin: bool, + external: Option<&ExternalCommandProjection>, + discovery_pending: bool, + builtin_reconfirmation_required: bool, +) -> CommandRoute { + match qualifier { + CommandQualifier::Builtin => { + if has_builtin { + CommandRoute::Builtin + } else { + CommandRoute::UnknownBuiltin + } + } + CommandQualifier::External => CommandRoute::External, + CommandQualifier::Unqualified => { + if builtin_reconfirmation_required { + return CommandRoute::AskForCollisionChoice; + } + if discovery_pending { + return CommandRoute::WaitForDiscovery; + } + if let Some(collision) = external.and_then(|command| command.native_collision.as_ref()) + { + return match collision.selected_candidate_id.as_deref() { + Some(selected) if selected == collision.external_candidate_id => { + CommandRoute::External + } + Some(selected) if selected == collision.native_candidate_id => { + CommandRoute::Builtin + } + _ => CommandRoute::AskForCollisionChoice, + }; + } + if has_builtin { + CommandRoute::Builtin + } else { + CommandRoute::External + } + } + } +} + + +impl ChatMode { + fn external_conflict_preferences(&self) -> ExternalSourceConflictPreferences { + ExternalSourceConflictPreferences { + choices: self.external_source_conflict_choices.clone(), + lineage_current_keys: self.external_source_conflict_lineage_current_keys.clone(), + conflicted_candidate_ids: self.external_source_conflicted_candidate_ids.clone(), + } + } + + fn update_external_source_view( + &self, + chat_view: &mut ChatView, + snapshot: &ExternalSourceCatalogSnapshot, + ) { + let preferences = self.external_conflict_preferences(); + chat_view.set_external_source_state( + external_command_projections(snapshot, &preferences.choices), + snapshot.discovery_pending, + builtin_reconfirmation_names(&preferences), + ); + } + + fn take_external_tool_notice( + &mut self, + snapshot: &ExternalSourceCatalogSnapshot, + ) -> Option { + let next_key = external_tool_pending_notice_key(snapshot); + if next_key == self.external_tool_notice_key { + return None; + } + self.external_tool_notice_key = next_key; + let approvals = snapshot.tool_approval_requests.len(); + let conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning + | ExternalSourceDiagnosticSeverity::Error + ) + }) + .count(); + if approvals + conflicts + diagnostics == 0 { + None + } else { + Some(format!( + "External tools need attention: {approvals} approvals, {conflicts} name conflicts, {diagnostics} diagnostics - run /external-tools" + )) + } + } + + fn handle_external_tool_review( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let action = match parse_external_tool_review_action( + arguments, + self.external_source_snapshot.as_ref(), + self.external_tool_review_snapshot.as_ref(), + ) { + Ok(action) => action, + Err(error) => { + chat_view.set_status(Some(error)); + return; + } + }; + if matches!(action, ExternalToolReviewAction::Show) { + self.external_tool_review_snapshot = self.external_source_snapshot.clone(); + chat_view.show_info_popup(external_tool_review_text( + self.external_tool_review_snapshot.as_ref(), + )); + return; + } + + if self.external_tool_mutation_rx.is_some() { + chat_view.set_status(Some( + "An external tool update is already running; input and cancellation remain available." + .to_string(), + )); + return; + } + + let workspace = self.workspace_path_for_sync(chat_state); + let pending_status = match &action { + ExternalToolReviewAction::Refresh => "Refreshing external tools", + ExternalToolReviewAction::Decide { approved: true, .. } => "Enabling external tool", + ExternalToolReviewAction::Decide { + approved: false, .. + } => "Disabling external tool", + ExternalToolReviewAction::Choose { .. } => "Selecting external tool provider", + ExternalToolReviewAction::Show => unreachable!(), + }; + let task_action = action.clone(); + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let result = match &task_action { + ExternalToolReviewAction::Refresh => { + external_source_snapshot(Some(&workspace), true).await + } + ExternalToolReviewAction::Decide { + approval_key, + decision_key, + approved, + } => { + set_external_tool_target_decision( + Some(&workspace), + approval_key, + decision_key, + *approved, + ) + .await + } + ExternalToolReviewAction::Choose { + conflict_key, + candidate_id, + } => { + set_external_tool_conflict_choice(Some(&workspace), conflict_key, candidate_id) + .await + } + ExternalToolReviewAction::Show => unreachable!(), + } + .map_err(|error| error.to_string()); + let _ = sender.send(ExternalToolMutationResult { + action: task_action, + result, + }); + }); + self.external_tool_mutation_rx = Some(receiver); + chat_view.set_status(Some(format!( + "{pending_status}; you can continue typing or cancel other UI work" + ))); + } + + fn poll_external_tool_mutation(&mut self, chat_view: &mut ChatView) -> bool { + let outcome = match self + .external_tool_mutation_rx + .as_ref() + .map(Receiver::try_recv) + { + Some(Ok(outcome)) => outcome, + Some(Err(MpscTryRecvError::Empty)) | None => return false, + 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." + .to_string(), + )); + return true; + } + }; + self.external_tool_mutation_rx = None; + match outcome.result { + Ok(snapshot) => { + if external_tool_result_is_stale(self.external_source_snapshot.as_ref(), &snapshot) + { + chat_view.set_status(Some( + "External tool update completed; a newer catalog result is already displayed." + .to_string(), + )); + return true; + } + self.update_external_source_view(chat_view, &snapshot); + self.external_tool_notice_key = external_tool_pending_notice_key(&snapshot); + let approvals = snapshot.tool_approval_requests.len(); + let conflicts = snapshot + .tool_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let result_label = external_tool_mutation_result_label(&outcome.action, &snapshot); + self.external_source_snapshot = Some(snapshot); + if approvals + conflicts == 0 { + 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" + ))); + } + } + 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." + ))); + } + } + true + } + +} diff --git a/src/apps/cli/src/modes/chat/input.rs b/src/apps/cli/src/modes/chat/input.rs new file mode 100644 index 0000000000..e6fb3fbda1 --- /dev/null +++ b/src/apps/cli/src/modes/chat/input.rs @@ -0,0 +1,616 @@ +impl ChatMode { + fn handle_key_event( + &mut self, + key: KeyEvent, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> Result> { + if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { + return Ok(None); + } + + let modal_state = + ActionState::chat(chat_state.is_processing, self.any_popup_visible(chat_view)); + if let Some(action) = self.keymap.resolve_modal_safe(key, modal_state) { + return self.dispatch_action(action, modal_state, chat_view, chat_state, rt_handle); + } + + // ── Permission prompt intercepts all keys when active ── + if let Some(ref mut prompt) = chat_state.permission_prompt { + let action = prompt.handle_key_event(key); + match action { + PermissionAction::AllowOnce => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + tracing::info!("User allowed tool once: {}", tool_id); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => { + chat_state.permission_prompt = None; + chat_view.set_status(Some("Tool confirmed".to_string())); + } + Err(error) => { + tracing::error!("Failed to confirm tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } + } + PermissionAction::AllowAlways => { + let tool_id = prompt.tool_id.clone(); + let tool_name = prompt.tool_name().to_string(); + let agent = self.agent.clone(); + tracing::info!( + "User allowed tool {}: tool_id={}, tool_name={}", + ALLOW_ALWAYS_RUNTIME_SCOPE, + tool_id, + tool_name + ); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => { + self.runtime.approval_controller().allow_always(&tool_name); + chat_state.permission_prompt = None; + chat_view.set_status(Some(format!( + "Tool approved {ALLOW_ALWAYS_RUNTIME_SCOPE}" + ))); + } + Err(error) => { + tracing::error!("Failed to confirm tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } + } + PermissionAction::Reject(reason) => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + tracing::info!("User rejected tool: {}, reason: {}", tool_id, reason); + let reason_clone = reason.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.reject_tool(&tool_id, reason_clone)) + }) { + Ok(()) => { + chat_state.permission_prompt = None; + chat_view.set_status(Some(format!("Tool rejected: {}", reason))); + } + Err(error) => { + tracing::error!("Failed to reject tool: {}", error); + chat_view.set_status(Some(format!("Error: {error}"))); + } + } + } + PermissionAction::None => { + // Permission prompt consumed the key, no further action + } + } + return Ok(None); + } + + // ── Question prompt intercepts all keys when active ── + if let Some(ref mut prompt) = chat_state.question_prompt { + let action = prompt.handle_key_event(key); + match action { + QuestionAction::Submit(answers) => { + let tool_id = prompt.tool_id.clone(); + let agent = self.agent.clone(); + chat_state.question_prompt = None; + tracing::info!("User submitted answers for tool: {}", tool_id); + tokio::task::block_in_place(|| { + rt_handle.block_on(async move { + if let Err(e) = agent.submit_user_answers(&tool_id, answers).await { + tracing::error!("Failed to submit answers: {}", e); + } + }) + }); + chat_view.set_status(Some("Answers submitted".to_string())); + } + QuestionAction::Reject => { + let tool_id = prompt.tool_id.clone(); + chat_state.question_prompt = None; + tracing::info!("User dismissed question prompt: {}", tool_id); + chat_view.set_status(Some("Question dismissed".to_string())); + } + QuestionAction::None => { + // Question prompt consumed the key, no further action + } + } + return Ok(None); + } + + // ── Normal key handling ── + + // Host recovery keys win over configured actions while a popup is open. + if self.any_popup_visible(chat_view) { + let state = ActionState::chat(chat_state.is_processing, true); + if let Some(action) = self.keymap.resolve_reserved(key, state) { + return self.dispatch_action(action, state, chat_view, chat_state, rt_handle); + } + } + + // Info popup intercepts all keys when visible + if chat_view.info_popup_visible() { + match key.code { + KeyCode::Up => chat_view.info_popup_scroll_up(1), + KeyCode::Down => chat_view.info_popup_scroll_down(1), + KeyCode::PageUp => chat_view.info_popup_scroll_up(10), + KeyCode::PageDown => chat_view.info_popup_scroll_down(10), + KeyCode::Home => chat_view.info_popup_scroll_to_start(), + KeyCode::End => chat_view.info_popup_scroll_to_end(), + KeyCode::Esc => chat_view.dismiss_info_popup(), + _ => {} + } + return Ok(None); + } + + // Command palette intercepts all keys when visible + if chat_view.command_palette_visible() { + let action = chat_view.command_palette_handle_key(key); + match action { + PaletteAction::Execute(id) => { + return self.handle_palette_action(&id, chat_view, chat_state, rt_handle); + } + PaletteAction::Dismiss => self.navigate_back(chat_view), + PaletteAction::None => {} + } + return Ok(None); + } + + // Handle popup events first (when visible) + if chat_view.model_selector_visible() { + match key.code { + KeyCode::Up => chat_view.model_selector_up(), + KeyCode::Down => chat_view.model_selector_down(), + KeyCode::Enter => { + if let Some(selected) = chat_view.model_selector_confirm() { + chat_view.hide_model_selector(); + self.apply_model_selection(&selected, chat_view, chat_state, rt_handle); + } + } + KeyCode::Char('e') => { + if let Some(selected) = chat_view.model_selector_confirm() { + chat_view.hide_model_selector(); + self.edit_model(&selected, chat_view, rt_handle); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.theme_selector_visible() { + match key.code { + KeyCode::Up => { + chat_view.theme_selector_up(); + if let Some(selected) = chat_view.theme_selector_selected() { + self.preview_theme_selection(&selected, chat_view); + } + } + KeyCode::Down => { + chat_view.theme_selector_down(); + if let Some(selected) = chat_view.theme_selector_selected() { + self.preview_theme_selection(&selected, chat_view); + } + } + KeyCode::Enter => { + if let Some(selected) = chat_view.theme_selector_confirm() { + chat_view.hide_theme_selector(); + self.apply_theme_selection(&selected, chat_view); + chat_view.commit_theme_preview(); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.agent_selector_visible() { + match key.code { + 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); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.session_selector_visible() { + let action = chat_view.session_selector_handle_key(key); + match action { + SessionAction::Switch(item) => { + return Ok(Some(ChatExitReason::SwitchSession(item.session_id))); + } + SessionAction::Delete(item) => { + self.handle_session_delete(&item, chat_view, chat_state, rt_handle); + } + SessionAction::Close | SessionAction::None => {} + } + return Ok(None); + } + + if chat_view.skill_selector_visible() { + match key.code { + KeyCode::Up => chat_view.skill_selector_up(), + KeyCode::Down => chat_view.skill_selector_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.skill_selector_confirm() { + self.handle_skill_selector_action(action, chat_view, chat_state, rt_handle); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.subagent_selector_visible() { + match key.code { + KeyCode::Up => chat_view.subagent_selector_up(), + KeyCode::Down => chat_view.subagent_selector_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(action) = chat_view.subagent_selector_confirm() { + self.handle_subagent_selector_action( + action, chat_view, chat_state, rt_handle, + ); + } + } + // Note: Esc is handled globally for navigation back + _ => {} + } + return Ok(None); + } + + if chat_view.mcp_selector_visible() { + match key.code { + KeyCode::Up => chat_view.mcp_selector_up(), + KeyCode::Down => chat_view.mcp_selector_down(), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(selected) = chat_view.mcp_selector_confirm() { + self.toggle_mcp_server(&selected.id, chat_view); + } + } + KeyCode::Char('a') => { + // Open add dialog (hide selector first) + chat_view.hide_mcp_selector(); + chat_view.show_mcp_add_dialog(); + } + KeyCode::Char('d') => { + if let Some(selected) = chat_view.mcp_selector_confirm() { + // First press: enter confirm-delete mode + // Second press: actually delete (handled by confirm_delete state) + if chat_view.mcp_selector_is_confirm_delete(&selected.id) { + self.delete_mcp_server(&selected.id, chat_view); + } else { + chat_view.mcp_selector_start_confirm_delete(selected.id.clone()); + } + } + } + KeyCode::Char('e') => { + chat_view.hide_mcp_selector(); + self.open_mcp_config(chat_state); + } + // Note: Esc is handled globally for navigation back + _ => { + // Any other key cancels the confirm-delete state + chat_view.mcp_selector_cancel_confirm_delete(); + } + } + return Ok(None); + } + + if chat_view.mcp_add_dialog_visible() { + let action = chat_view.mcp_add_dialog_handle_key(key); + match action { + McpAddAction::Confirm { name, config_json } => { + self.add_mcp_server(&name, &config_json, chat_view); + } + McpAddAction::Cancel => { + // Re-open the MCP selector + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + McpAddAction::None => {} + } + return Ok(None); + } + + if chat_view.provider_selector_visible() { + if let Some(selection) = chat_view.provider_selector_handle_key(key) { + self.handle_provider_selection(selection, chat_view); + } + return Ok(None); + } + + if chat_view.model_config_form_visible() { + let action = chat_view.model_config_form_handle_key(key); + match action { + ModelFormAction::Save(result) => { + if result.editing_model_id.is_some() { + self.update_existing_model(result, chat_view, chat_state, rt_handle); + } else { + self.save_new_model(result, chat_view, chat_state, rt_handle); + } + } + ModelFormAction::Cancel => { + chat_view.set_status(Some("Model form cancelled".to_string())); + } + ModelFormAction::None => {} + } + return Ok(None); + } + + if chat_view.login_form_visible() { + self.refresh_account_panel_live(chat_view); + let action = chat_view.login_form_handle_key(key); + return self.handle_login_form_action(action, chat_view, chat_state, rt_handle); + } + + if let Some(action) = self + .keymap + .resolve(key, ActionState::chat(chat_state.is_processing, false)) + { + return self.dispatch_action( + action, + ActionState::chat(chat_state.is_processing, false), + chat_view, + chat_state, + rt_handle, + ); + } + + match (key.code, key.modifiers) { + (KeyCode::Backspace, _) => { + chat_view.handle_backspace(); + } + + (KeyCode::Left, _) => { + chat_view.move_cursor_left(); + } + (KeyCode::Right, _) => { + chat_view.move_cursor_right(); + } + + (KeyCode::Home, _) => { + chat_view.set_cursor_home(); + } + + (KeyCode::End, _) => { + chat_view.set_cursor_end(); + } + + (KeyCode::Esc, _) => { + if chat_view.browse_mode { + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("Exited browse mode".to_string())); + } + } + + (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) + if !c.is_control() && c != '\u{0}' => + { + chat_view.handle_char(c); + } + + _ => {} + } + + Ok(None) + } + + /// Apply an exit reason from handle_key_event (shared by normal and batch paths). + fn apply_exit_reason(reason: ChatExitReason, context: ChatEventContext<'_>) { + let ChatEventContext { + this, + chat_view, + chat_state, + session_id, + rt_handle, + should_quit, + exit_reason, + } = context; + match reason { + ChatExitReason::SwitchSession(new_session_id) => { + match this.switch_to_session( + &new_session_id, + session_id, + chat_state, + chat_view, + rt_handle, + ) { + Ok(()) => tracing::info!("Switched to session: {}", new_session_id), + Err(e) => { + chat_state.add_system_message(format!("Failed to switch session: {}", e)); + tracing::error!("Failed to switch session: {}", e); + } + } + } + ChatExitReason::NewSession => { + match this.create_new_session(session_id, chat_state, chat_view, rt_handle) { + Ok(()) => tracing::info!("Created new session: {}", session_id), + Err(e) => { + chat_state + .add_system_message(format!("Failed to create new session: {}", e)); + tracing::error!("Failed to create new session: {}", e); + } + } + } + other => { + *should_quit = true; + *exit_reason = other; + } + } + } + + /// Handle non-key events (Mouse, Paste, Resize, etc.). + fn handle_non_key_event( + event: Event, + context: ChatEventContext<'_>, + ) -> Result { + let mut outcome = NonKeyEventOutcome::default(); + match event { + Event::Mouse(mouse) => { + if context.chat_view.command_palette_captures_mouse(&mouse) { + let action = context.chat_view.command_palette_handle_mouse(&mouse); + match action { + PaletteAction::Execute(id) => { + if let Some(reason) = context.this.handle_palette_action( + &id, + context.chat_view, + context.chat_state, + context.rt_handle, + )? { + Self::apply_exit_reason( + reason, + ChatEventContext { + this: &mut *context.this, + chat_view: &mut *context.chat_view, + chat_state: &mut *context.chat_state, + session_id: &mut *context.session_id, + rt_handle: context.rt_handle, + should_quit: &mut *context.should_quit, + exit_reason: &mut *context.exit_reason, + }, + ); + } + } + PaletteAction::Dismiss => context.this.navigate_back(context.chat_view), + PaletteAction::None => {} + } + } else if context.chat_view.provider_selector_captures_mouse(&mouse) { + if let Some(selection) = + context.chat_view.provider_selector_handle_mouse(&mouse) + { + context + .this + .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_skill_action() { + context.this.handle_skill_selector_action( + action, + context.chat_view, + context.chat_state, + context.rt_handle, + ); + } + if let Some(action) = context.chat_view.take_pending_subagent_action() { + context.this.handle_subagent_selector_action( + action, + context.chat_view, + context.chat_state, + context.rt_handle, + ); + } + } else { + match mouse.kind { + MouseEventKind::ScrollUp => { + let total = context.chat_view.count_message_lines(context.chat_state); + context.chat_view.scroll_up(3, total); + } + MouseEventKind::ScrollDown => { + context.chat_view.scroll_down(3); + } + MouseEventKind::Down(MouseButton::Left) => { + let _ = context + .chat_view + .begin_mouse_selection(mouse.column, mouse.row); + } + MouseEventKind::Drag(MouseButton::Left) => { + let _ = context + .chat_view + .update_mouse_selection(mouse.column, mouse.row); + } + MouseEventKind::Up(MouseButton::Left) => { + match context + .chat_view + .complete_mouse_selection_or_click(mouse.column, mouse.row) + { + MouseGestureOutcome::CopyText(text) => { + match Clipboard::new().and_then(|mut cb| cb.set_text(text)) { + Ok(()) => context + .chat_view + .set_status(Some("Copied to clipboard".to_string())), + Err(_) => context.chat_view.set_status(Some( + "Failed to copy selection".to_string(), + )), + } + } + MouseGestureOutcome::Click(col, row) => { + context.chat_view.handle_mouse_click(col, row); + } + MouseGestureOutcome::None => {} + } + } + MouseEventKind::Moved + if !context + .chat_view + .update_mouse_selection(mouse.column, mouse.row) => + { + context.chat_view.handle_mouse_move(mouse.column, mouse.row); + } + _ => {} + } + } + if let Some(action_id) = context.chat_view.take_pending_command() { + if let Some(reason) = context.this.handle_action_id( + &action_id, + context.chat_view, + context.chat_state, + context.rt_handle, + )? { + Self::apply_exit_reason( + reason, + ChatEventContext { + this: &mut *context.this, + chat_view: &mut *context.chat_view, + chat_state: &mut *context.chat_state, + session_id: &mut *context.session_id, + rt_handle: context.rt_handle, + should_quit: &mut *context.should_quit, + exit_reason: &mut *context.exit_reason, + }, + ); + } + } + if let Some(theme) = context.chat_view.take_pending_theme_preview() { + context + .this + .preview_theme_selection(&theme, context.chat_view); + } + if let Some(server_id) = context.chat_view.take_pending_mcp_toggle() { + context + .this + .toggle_mcp_server(&server_id, context.chat_view); + } + outcome.request_redraw = true; + } + Event::Paste(text) => { + if context.chat_view.mcp_add_dialog_visible() { + context.chat_view.mcp_add_dialog_handle_paste(&text); + } else if context.chat_view.login_form_visible() { + context.chat_view.login_form_insert_paste(&text); + } else if context.chat_state.permission_prompt.is_none() + && context.chat_state.question_prompt.is_none() + && !context.this.any_popup_visible(context.chat_view) + { + context.chat_view.insert_paste(&text); + } + outcome.request_redraw = true; + } + Event::Resize(_, _) => { + outcome.resize_observed = true; + } + _ => {} + } + Ok(outcome) + } + +} diff --git a/src/apps/cli/src/modes/chat/mcp.rs b/src/apps/cli/src/modes/chat/mcp.rs new file mode 100644 index 0000000000..1c32442e57 --- /dev/null +++ b/src/apps/cli/src/modes/chat/mcp.rs @@ -0,0 +1,504 @@ +impl ChatMode { + /// Show MCP server selector popup + fn show_mcp_selector( + &self, + chat_view: &mut ChatView, + _chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let items = self.get_mcp_items(rt_handle); + // Show even if empty — user can press 'a' to add + chat_view.show_mcp_selector(items); + } + + /// Get MCP server items for display + fn get_mcp_items(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc, + None => return Vec::new(), + }; + + let server_manager = mcp_service.server_manager(); + let config_service = mcp_service.config_service(); + + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let configs = match config_service.load_all_configs().await { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to load MCP configs: {}", e); + return Vec::new(); + } + }; + + let tool_registry = + bitfun_core::agentic::tools::registry::get_global_tool_registry(); + let registry_lock = tool_registry.read().await; + let all_tools = registry_lock.get_all_tools(); + + let mut items = Vec::new(); + for config in configs { + let status = if !config.enabled { + "Stopped".to_string() + } else { + // Avoid blocking UI while a slow auto-start server holds internal write lock. + match tokio::time::timeout( + Duration::from_millis(30), + server_manager.get_server_status(&config.id), + ) + .await + { + Ok(Ok(s)) => format!("{:?}", s), + Ok(Err(_)) => "Unknown".to_string(), + Err(_) => "Starting".to_string(), + } + }; + + // Count tools from this server + let prefix = format!("mcp_{}_", config.id); + let tool_count = all_tools + .iter() + .filter(|t| t.name().starts_with(&prefix)) + .count(); + + let server_type = format!("{:?}", config.server_type).to_lowercase(); + + items.push(McpItem { + id: config.id.clone(), + name: config.name.clone(), + server_type, + status, + tool_count, + }); + } + items + }) + }) + } + + /// Schedule an MCP server toggle (deferred to allow loading state to render) + fn toggle_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { + return; + } + + // Set loading indicator immediately — will be rendered before execution + chat_view.mcp_selector_set_loading(Some(server_id.to_string())); + self.pending_mcp_op = Some(PendingMcpOp::Toggle(server_id.to_string())); + } + + /// Execute MCP server toggle (called from main loop after render) + fn execute_mcp_toggle( + &mut self, + server_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + chat_view.mcp_selector_set_loading(None); + return; + } + }; + + let server_manager = mcp_service.server_manager(); + let task_server_id = server_id.to_string(); + let tracked_server_id = task_server_id.clone(); + + let handle = rt_handle.spawn(async move { + let status = server_manager.get_server_status(&task_server_id).await; + match status { + Ok(bitfun_core::service::mcp::MCPServerStatus::Connected) + | Ok(bitfun_core::service::mcp::MCPServerStatus::Healthy) => { + server_manager.stop_server(&task_server_id).await + } + _ => server_manager.start_server(&task_server_id).await, + } + }); + + self.pending_mcp_tasks.push(PendingMcpTask::Toggle { + server_id: tracked_server_id, + handle, + }); + } + + fn is_mcp_server_task_running(&self, server_id: &str) -> bool { + self.pending_mcp_tasks.iter().any(|task| match task { + PendingMcpTask::Toggle { server_id: id, .. } + | PendingMcpTask::Delete { server_id: id, .. } => id == server_id, + PendingMcpTask::Add { .. } => false, + }) + } + + fn has_pending_mcp_add_task(&self) -> bool { + self.pending_mcp_tasks + .iter() + .any(|task| matches!(task, PendingMcpTask::Add { .. })) + } + + fn poll_mcp_task_completion( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) -> bool { + let mut changed = false; + let mut i = 0; + while i < self.pending_mcp_tasks.len() { + let finished = match &self.pending_mcp_tasks[i] { + PendingMcpTask::Toggle { handle, .. } + | PendingMcpTask::Add { handle, .. } + | PendingMcpTask::Delete { handle, .. } => handle.is_finished(), + }; + if !finished { + i += 1; + continue; + } + + let task = self.pending_mcp_tasks.swap_remove(i); + changed = true; + match task { + PendingMcpTask::Toggle { server_id, handle } => { + let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); + + match join_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + tracing::error!("Failed to toggle MCP server {}: {}", server_id, e); + chat_state.add_system_message(format!( + "Failed to toggle MCP server '{}': {}", + server_id, e + )); + } + Err(e) => { + tracing::error!("MCP toggle task join error for {}: {}", server_id, e); + chat_state.add_system_message(format!( + "MCP server '{}' task failed: {}", + server_id, e + )); + } + } + + chat_view.mcp_selector_set_loading(None); + let updated_items = self.get_mcp_items(rt_handle); + chat_view.mcp_selector_update_items(updated_items); + } + PendingMcpTask::Add { name, handle } => { + let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); + + match join_result { + Ok(Ok(())) => { + chat_state.add_system_message(format!( + "MCP server '{}' added and started", + name + )); + self.show_mcp_selector(chat_view, chat_state, rt_handle); + } + Ok(Err(e)) => { + chat_state + .add_system_message(format!("Failed to add MCP server: {}", e)); + } + Err(e) => { + chat_state.add_system_message(format!( + "MCP add task failed for '{}': {}", + name, e + )); + } + } + chat_view.set_status(None); + } + PendingMcpTask::Delete { server_id, handle } => { + let join_result = tokio::task::block_in_place(|| rt_handle.block_on(handle)); + + match join_result { + Ok(Ok(())) => { + chat_state + .add_system_message(format!("MCP server '{}' deleted", server_id)); + } + Ok(Err(e)) => { + chat_state + .add_system_message(format!("Failed to delete MCP server: {}", e)); + } + Err(e) => { + chat_state.add_system_message(format!( + "MCP delete task failed for '{}': {}", + server_id, e + )); + } + } + + chat_view.mcp_selector_set_loading(None); + let updated_items = self.get_mcp_items(rt_handle); + if updated_items.is_empty() { + chat_view.hide_mcp_selector(); + } else { + chat_view.mcp_selector_update_items(updated_items); + } + } + } + } + changed + } + + /// Schedule adding a new MCP server (deferred to allow loading state to render) + fn add_mcp_server(&mut self, name: &str, config_json_str: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.has_pending_mcp_add_task() { + return; + } + + chat_view.set_status(Some(format!("Adding MCP server '{}'...", name))); + self.pending_mcp_op = Some(PendingMcpOp::Add { + name: name.to_string(), + config_json: config_json_str.to_string(), + }); + } + + /// Execute MCP server add (called from main loop after render) + fn execute_mcp_add( + &mut self, + name: &str, + config_json_str: &str, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + return; + } + }; + + let config_value: serde_json::Value = match serde_json::from_str(config_json_str) { + Ok(v) => v, + Err(e) => { + chat_state.add_system_message(format!("Invalid JSON: {}", e)); + _chat_view.set_status(None); + return; + } + }; + + let name_owned = name.to_string(); + let task_name = name_owned.clone(); + let handle = rt_handle.spawn(async move { + let config_obj = config_value.as_object().ok_or_else(|| { + bitfun_core::util::errors::BitFunError::Validation( + "MCP server config must be a JSON object".to_string(), + ) + })?; + + let server_type = match config_obj.get("type").and_then(|v| v.as_str()) { + Some("sse") => bitfun_core::service::mcp::MCPServerType::Remote, + Some("streamable-http") | Some("streamable_http") | Some("http") => { + bitfun_core::service::mcp::MCPServerType::Remote + } + _ => bitfun_core::service::mcp::MCPServerType::Local, + }; + + let transport = match config_obj.get("type").and_then(|v| v.as_str()) { + Some("sse") => bitfun_core::service::mcp::MCPServerTransport::Sse, + Some("streamable-http") | Some("streamable_http") | Some("http") => { + bitfun_core::service::mcp::MCPServerTransport::StreamableHttp + } + _ => bitfun_core::service::mcp::MCPServerTransport::Stdio, + }; + + let command = config_obj + .get("command") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let args = config_obj + .get("args") + .and_then(|v| v.as_array()) + .map(|values| { + values + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + let env = config_obj + .get("env") + .and_then(|v| v.as_object()) + .map(|map| { + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + }) + .unwrap_or_default(); + let headers = config_obj + .get("headers") + .and_then(|v| v.as_object()) + .map(|map| { + map.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect::>() + }) + .unwrap_or_default(); + let url = config_obj + .get("url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let auto_start = config_obj + .get("autoStart") + .or_else(|| config_obj.get("auto_start")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let enabled = config_obj + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + let config = bitfun_core::service::mcp::MCPServerConfig { + id: name_owned.clone(), + name: name_owned.clone(), + server_type, + transport: Some(transport), + command, + args, + env, + headers, + url, + auto_start, + enabled, + location: bitfun_core::service::mcp::ConfigLocation::User, + capabilities: Vec::new(), + settings: Default::default(), + oauth: config_obj + .get("oauth") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), + xaa: config_obj + .get("xaa") + .cloned() + .and_then(|value| serde_json::from_value(value).ok()), + }; + + mcp_service.server_manager().add_server(config).await?; + + Ok::<(), bitfun_core::util::errors::BitFunError>(()) + }); + self.pending_mcp_tasks.push(PendingMcpTask::Add { + name: task_name, + handle, + }); + } + + /// Schedule deleting an MCP server (deferred to allow loading state to render) + fn delete_mcp_server(&mut self, server_id: &str, chat_view: &mut ChatView) { + if self.pending_mcp_op.is_some() || self.is_mcp_server_task_running(server_id) { + return; + } + + chat_view.mcp_selector_set_loading(Some(server_id.to_string())); + chat_view.mcp_selector_cancel_confirm_delete(); + self.pending_mcp_op = Some(PendingMcpOp::Delete(server_id.to_string())); + } + + /// Execute MCP server delete (called from main loop after render) + fn execute_mcp_delete( + &mut self, + server_id: &str, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let mcp_service = match crate::get_mcp_service() { + Some(svc) => svc.clone(), + None => { + chat_state.add_system_message("MCP service not initialized".to_string()); + chat_view.mcp_selector_set_loading(None); + return; + } + }; + + let server_id_owned = server_id.to_string(); + let task_server_id = server_id_owned.clone(); + let handle = rt_handle.spawn(async move { + // Delete config first so UI can reflect removal immediately even if stop is blocked. + mcp_service + .config_service() + .delete_server_config(&server_id_owned) + .await?; + + // Best-effort async cleanup: slow startups may hold process write lock for a long time. + // Retry stop with short timeout, without blocking the delete operation completion. + let cleanup_service = mcp_service.clone(); + let cleanup_server_id = server_id_owned.clone(); + tokio::spawn(async move { + for attempt in 1..=20 { + let stop_result = tokio::time::timeout( + Duration::from_millis(250), + cleanup_service + .server_manager() + .stop_server(&cleanup_server_id), + ) + .await; + + match stop_result { + Ok(Ok(())) => return, + Ok(Err(bitfun_core::util::errors::BitFunError::NotFound(_))) => return, + Ok(Err(e)) => { + tracing::debug!( + "Best-effort MCP stop failed: id={} attempt={} error={}", + cleanup_server_id, + attempt, + e + ); + } + Err(_) => { + tracing::debug!( + "Best-effort MCP stop timed out: id={} attempt={}", + cleanup_server_id, + attempt + ); + } + } + + tokio::time::sleep(Duration::from_millis(250)).await; + } + + tracing::warn!( + "Best-effort MCP stop exhausted retries: id={}", + cleanup_server_id + ); + }); + + Ok::<(), bitfun_core::util::errors::BitFunError>(()) + }); + + self.pending_mcp_tasks.push(PendingMcpTask::Delete { + server_id: task_server_id, + handle, + }); + } + + /// Open MCP config file in system editor or show its path + fn open_mcp_config(&self, chat_state: &mut ChatState) { + match bitfun_core::infrastructure::try_get_path_manager_arc() { + Ok(path_manager) => { + let config_file = path_manager.app_config_file(); + chat_state.add_system_message(format!( + "MCP servers are configured in:\n {}\n\n\ + Edit the \"mcp_servers\" section. Example (Cursor format):\n\ + {{\n \"mcp_servers\": {{\n \"mcpServers\": {{\n \ + \"my-server\": {{\n \"type\": \"stdio\",\n \ + \"command\": \"npx\",\n \"args\": [\"-y\", \"@modelcontextprotocol/server-xxx\"]\n \ + }}\n }}\n }}\n}}", + config_file.display() + )); + } + Err(_) => { + chat_state.add_system_message( + "Could not determine config file path. Check ~/.config/bitfun/config/app.json" + .to_string(), + ); + } + } + } + +} diff --git a/src/apps/cli/src/modes/chat/provider_models.rs b/src/apps/cli/src/modes/chat/provider_models.rs new file mode 100644 index 0000000000..d398501091 --- /dev/null +++ b/src/apps/cli/src/modes/chat/provider_models.rs @@ -0,0 +1,259 @@ +impl ChatMode { + /// Handle provider selection result (step 1 → step 2) + fn handle_provider_selection(&self, selection: ProviderSelection, chat_view: &mut ChatView) { + match selection { + ProviderSelection::Provider(template) => { + let default_model = template.models.first().cloned().unwrap_or_default(); + chat_view.show_model_config_form_from_provider( + &template.name, + &template.base_url, + &template.format, + &default_model, + ); + } + ProviderSelection::Custom => { + chat_view.show_model_config_form_custom(); + } + } + } + + /// Save new model to global config + fn save_new_model( + &self, + result: ModelFormResult, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = format!( + "model_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + ); + + // Parse custom headers JSON if provided + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { + None + } else { + Some(result.custom_headers_mode.clone()) + }, + custom_request_body, + ..Default::default() + }; + + let success = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service.add_ai_model(model_config).await { + tracing::error!("Failed to add AI model: {}", e); + return false; + } + + // Auto-set as primary model if no primary model exists + match config_service + .get_config::(None) + .await + { + Ok(global_config) => { + let has_primary = global_config + .ai + .default_models + .primary + .as_ref() + .map(|p| !p.is_empty()) + .unwrap_or(false); + if !has_primary { + if let Err(e) = config_service + .set_config("ai.default_models.primary", &model_id) + .await + { + tracing::warn!("Failed to auto-set primary model: {}", e); + } else { + tracing::info!("Auto-set primary model: {}", model_id); + } + } + } + Err(e) => { + tracing::warn!("Failed to read config for auto-primary: {}", e); + } + } + + true + }) + }); + + if success { + chat_view.set_status(Some(format!("Model added: {}", result.name))); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); + tracing::info!("Added new AI model: {} ({})", model_id, result.model_name); + } else { + chat_view.set_status(Some("Failed to add model".to_string())); + } + } + + /// Fetch full model config and open the edit form + fn edit_model( + &self, + selected: &ModelItem, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = selected.id.clone(); + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + models.into_iter().find(|m| m.id == model_id) + }) + }); + + match result { + Some(model) => { + let form_data = ModelFormResult { + editing_model_id: Some(model.id.clone()), + name: model.name, + model_name: model.model_name, + base_url: model.base_url, + api_key: model.api_key, + provider_format: model.provider.clone(), + context_window: model.context_window.unwrap_or(128000), + max_tokens: model.max_tokens.unwrap_or(8192), + enable_thinking: model.enable_thinking_process, + support_preserved_thinking: model.inline_think_in_text, + skip_ssl_verify: model.skip_ssl_verify, + custom_headers: model + .custom_headers + .map(|h| serde_json::to_string(&h).unwrap_or_default()) + .unwrap_or_default(), + custom_headers_mode: model + .custom_headers_mode + .unwrap_or_else(|| "merge".to_string()), + custom_request_body: model.custom_request_body.unwrap_or_default(), + }; + chat_view.show_model_config_form_for_edit(&model.id, &form_data); + } + None => { + chat_view.set_status(Some("Failed to load model configuration".to_string())); + } + } + } + + /// Update an existing model in global config + fn update_existing_model( + &self, + result: ModelFormResult, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let model_id = match &result.editing_model_id { + Some(id) => id.clone(), + None => return, + }; + + let custom_headers: Option> = + if result.custom_headers.is_empty() { + None + } else { + serde_json::from_str(&result.custom_headers).ok() + }; + + let custom_request_body: Option = if result.custom_request_body.is_empty() { + None + } else { + Some(result.custom_request_body.clone()) + }; + + let model_config = bitfun_core::service::config::AIModelConfig { + id: model_id.clone(), + name: result.name.clone(), + provider: result.provider_format.clone(), + model_name: result.model_name.clone(), + base_url: result.base_url.clone(), + api_key: result.api_key.clone(), + context_window: Some(result.context_window), + max_tokens: Some(result.max_tokens), + enabled: true, + enable_thinking_process: result.enable_thinking || result.support_preserved_thinking, + skip_ssl_verify: result.skip_ssl_verify, + custom_headers, + custom_headers_mode: if result.custom_headers_mode.is_empty() + || result.custom_headers_mode == "merge" + { + None + } else { + Some(result.custom_headers_mode.clone()) + }, + custom_request_body, + ..Default::default() + }; + + let success = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return false; + } + }; + + if let Err(e) = config_service + .update_ai_model(&model_id, model_config) + .await + { + tracing::error!("Failed to update AI model: {}", e); + return false; + } + + true + }) + }); + + if success { + chat_view.set_status(Some(format!("Model updated: {}", result.name))); + chat_state.current_model_name = format!("{} / {}", result.model_name, result.name); + tracing::info!("Updated AI model: {}", model_id); + } else { + chat_view.set_status(Some("Failed to update model".to_string())); + } + } +} diff --git a/src/apps/cli/src/modes/chat/resize.rs b/src/apps/cli/src/modes/chat/resize.rs new file mode 100644 index 0000000000..fd3655cf73 --- /dev/null +++ b/src/apps/cli/src/modes/chat/resize.rs @@ -0,0 +1,143 @@ +use std::time::{Duration, Instant}; + +#[derive(Debug)] +pub(super) struct ResizeRedrawState { + debounce: Duration, + pending_since: Option, +} + +impl ResizeRedrawState { + pub(super) const fn new(debounce: Duration) -> Self { + Self { + debounce, + pending_since: None, + } + } + + pub(super) fn observe(&mut self, observed_at: Instant) { + self.pending_since = Some(observed_at); + } + + pub(super) fn take_ready(&mut self, now: Instant) -> bool { + let Some(observed_at) = self.pending_since else { + return false; + }; + if now.saturating_duration_since(observed_at) < self.debounce { + return false; + } + self.pending_since = None; + true + } + + pub(super) const fn is_pending(&self) -> bool { + self.pending_since.is_some() + } + + pub(super) const fn can_render(&self) -> bool { + !self.is_pending() + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use ratatui::Terminal; + + use super::ResizeRedrawState; + use crate::chat_state::ChatState; + use crate::ui::chat::ChatView; + use crate::ui::theme::Theme; + + const DEBOUNCE: Duration = Duration::from_millis(75); + + #[test] + fn resize_redraw_waits_for_the_quiet_period() { + let started_at = Instant::now(); + let mut state = ResizeRedrawState::new(DEBOUNCE); + + state.observe(started_at); + + assert!(!state.can_render()); + assert!(!state.take_ready(started_at + Duration::from_millis(74))); + assert!(state.take_ready(started_at + DEBOUNCE)); + assert!(state.can_render()); + } + + #[test] + fn resize_redraw_restarts_the_quiet_period_for_a_burst() { + let started_at = Instant::now(); + let mut state = ResizeRedrawState::new(DEBOUNCE); + + state.observe(started_at); + state.observe(started_at + Duration::from_millis(40)); + + assert!(!state.take_ready(started_at + DEBOUNCE)); + assert!(state.take_ready(started_at + Duration::from_millis(115))); + } + + #[test] + fn resize_redraw_is_consumed_once() { + let started_at = Instant::now(); + let mut state = ResizeRedrawState::new(DEBOUNCE); + + state.observe(started_at); + + assert!(state.take_ready(started_at + DEBOUNCE)); + assert!(!state.take_ready(started_at + DEBOUNCE)); + assert!(!state.is_pending()); + } + + #[test] + fn active_streaming_content_reflows_at_the_resized_width() { + let mut chat_state = ChatState::new( + "session-resize".to_string(), + "Resize test".to_string(), + "general".to_string(), + None, + ); + chat_state.handle_turn_started("turn-resize", "show the current status"); + chat_state.handle_text_chunk( + "STARTMARK alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi omicron pi rho sigma ENDMARK", + ); + + let mut chat_view = ChatView::new(Theme::dark(), Vec::new()); + let mut terminal = Terminal::new(TestBackend::new(48, 30)).expect("test terminal"); + terminal + .draw(|frame| chat_view.render(frame, &chat_state)) + .expect("initial render"); + let wide_buffer = terminal.backend().buffer(); + let wide_start = marker_row(wide_buffer, "STARTMARK"); + let wide_end = marker_row(wide_buffer, "ENDMARK"); + + terminal.backend_mut().resize(24, 30); + chat_view.invalidate_lines_cache(); + terminal + .draw(|frame| chat_view.render(frame, &chat_state)) + .expect("resized render"); + + let buffer = terminal.backend().buffer(); + let narrow_start = marker_row(buffer, "STARTMARK"); + let narrow_end = marker_row(buffer, "ENDMARK"); + + assert_eq!(buffer.area, Rect::new(0, 0, 24, 30)); + assert!( + narrow_end - narrow_start > wide_end - wide_start, + "narrow rendering should wrap the same streaming content across more rows" + ); + } + + fn marker_row(buffer: &ratatui::buffer::Buffer, marker: &str) -> usize { + (0..buffer.area.height) + .find(|&y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + .contains(marker) + }) + .map(usize::from) + .unwrap_or_else(|| panic!("missing marker {marker:?} in rendered buffer")) + } +} diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs new file mode 100644 index 0000000000..23cccb0ea4 --- /dev/null +++ b/src/apps/cli/src/modes/chat/run.rs @@ -0,0 +1,626 @@ +impl ChatMode { + pub(crate) fn run( + &mut self, + existing_terminal: Option, + ) -> Result { + tracing::info!("Starting Chat mode, Agent: {}", self.agent_type); + if let Some(ws) = &self.workspace { + tracing::info!("Workspace: {}", ws); + } + + let mut terminal = match existing_terminal { + Some(t) => t, + None => init_terminal()?, + }; + + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + let theme = self.resolve_configured_theme(base, appearance, scheme); + let shortcut_hints = self.keymap.compact_hints(ActionState::chat(false, false)); + let mut chat_view = ChatView::new(theme, shortcut_hints); + + // Create or restore core session + let rt_handle = tokio::runtime::Handle::current(); + + let (mut session_id, mut chat_state) = if let Some(ref restore_id) = self.restore_session_id + { + // Restore existing session + tracing::info!("Restoring session: {}", restore_id); + let agent = self.agent.clone(); + let rid = restore_id.clone(); + + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + // Restore session in core (loads metadata, messages, managers) + let (summary, effective_workspace_path) = + agent.restore_session_in_current_workspace(&rid).await?; + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); + + // Load historical messages for UI display + let transcript = agent.get_transcript(&rid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: rid.clone(), + messages: Vec::new(), + } + }); + + let state = ChatState::from_session_transcript( + rid.clone(), + summary.session_name, + summary.agent_type, + effective_workspace, + &transcript, + ); + + tracing::info!( + "Session restored: {}, {} messages loaded", + rid, + transcript.messages.len() + ); + + Ok::<_, anyhow::Error>((rid, state)) + }) + })? + } else { + // Create new session + let session_id = tokio::task::block_in_place(|| { + rt_handle.block_on(self.agent.ensure_session(&self.agent_type)) + })?; + tracing::info!("Core session ready: {}", session_id); + + let state = ChatState::new( + session_id.clone(), + "CLI Session".to_string(), + self.agent_type.clone(), + self.workspace.clone(), + ); + (session_id, state) + }; + + // Keep ChatMode workspace in sync with the session's effective workspace + self.agent_type = chat_state.agent_type.clone(); + self.workspace = chat_state.workspace.clone(); + + let external_workspace = self.agent.workspace_path_buf(); + let (initial_external_sources, mut external_source_rx, conflict_preferences) = + tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let updates = + subscribe_external_source_updates(Some(&external_workspace)).await; + let snapshot = external_source_snapshot(Some(&external_workspace), false).await; + let preferences = external_source_conflict_choices().await.map(Into::into); + (snapshot, updates.ok(), preferences) + }) + }); + match conflict_preferences { + Ok(preferences) => self.replace_external_conflict_preferences(preferences), + Err(error) => tracing::warn!("External source preferences are unavailable: {}", error), + } + match initial_external_sources { + Ok(snapshot) => { + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let tool_notice = self.take_external_tool_notice(&snapshot); + self.update_external_source_view(&mut chat_view, &snapshot); + self.external_source_snapshot = Some(snapshot.clone()); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible content from external AI applications".to_string(), + )); + } else if let Some(notice) = tool_notice { + chat_view.set_status(Some(notice)); + } else if available + restricted > 0 || pending_conflicts > 0 { + chat_view.set_status(Some(format!( + "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" + ))); + } + } + Err(error) => { + tracing::warn!("External source discovery is unavailable: {}", error); + } + } + + // Load current model name for display + self.load_current_model_name(&mut chat_state, &rt_handle); + + if self.agent_type == "HarmonyOSDev" { + let deveco_home = std::env::var("DEVECO_HOME").ok(); + let missing = deveco_home + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + if missing { + chat_state.add_system_message( + "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." + .to_string(), + ); + } + } + + let mut event_rx = self.agent.event_source().subscribe(); + + // Send initial prompt if provided (from startup page input) + if let Some(prompt) = self.initial_prompt.take() { + tracing::info!("Sending initial prompt: {}", prompt); + if prompt.starts_with('/') { + // Slash commands will be handled in the main loop + chat_view.text_input.set_text(&prompt); + } else { + let display_name = agent_display_name(&self.agent_type); + chat_view.set_status(Some(format!("{} is thinking...", display_name))); + + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.send_message(prompt, &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started initial turn: {}", turn_id); + } + Err(e) => { + tracing::error!("Failed to send initial prompt: {}", e); + chat_view.set_status(Some(format!("Error: {}", e))); + } + } + } + } + + let mut exit_reason = ChatExitReason::Quit; + let mut should_quit = false; + let mut needs_redraw = true; + let mut subagent_parent_tools: HashMap = HashMap::new(); + let mut last_spinner_redraw = Instant::now(); + let mut event_reader = crate::ui::input::EventReader::default(); + let mut fatal_event_stream_error: Option = None; + let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); + let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); + let mut resize_redraw = ResizeRedrawState::new(resize_redraw_debounce); + + while !should_quit { + chat_view.set_action_state( + ActionState::chat(chat_state.is_processing, false), + &self.keymap, + ); + + // Keep spinner animation smooth without forcing full redraw every loop. + // Pause spinner updates while resize is still being debounced. + if resize_redraw.is_pending() { + last_spinner_redraw = Instant::now(); + } else if chat_state.is_processing { + if last_spinner_redraw.elapsed() >= spinner_redraw_interval { + needs_redraw = true; + last_spinner_redraw = Instant::now(); + } + } else { + last_spinner_redraw = Instant::now(); + } + + // Poll completion of non-blocking MCP operations before rendering. + if self.poll_mcp_task_completion(&mut chat_view, &mut chat_state, &rt_handle) { + needs_redraw = true; + } + if self.poll_external_tool_mutation(&mut chat_view) { + needs_redraw = true; + } + + let mut external_source_closed = false; + if let Some(receiver) = external_source_rx.as_mut() { + let mut latest = None; + for _ in 0..4 { + match receiver.try_recv() { + Ok(snapshot) => latest = Some(snapshot), + Err(TryRecvError::Lagged(_)) => continue, + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Closed) => { + external_source_closed = true; + break; + } + } + } + if let Some(snapshot) = latest { + let discovery_just_finished = self + .external_source_snapshot + .as_ref() + .is_some_and(|previous| previous.discovery_pending) + && !snapshot.discovery_pending; + let preferences = tokio::task::block_in_place(|| { + rt_handle + .block_on(external_source_conflict_choices()) + .map(Into::into) + }); + if let Ok(preferences) = preferences { + self.replace_external_conflict_preferences(preferences); + } + let tool_notice = self.take_external_tool_notice(&snapshot); + self.update_external_source_view(&mut chat_view, &snapshot); + if snapshot.discovery_pending { + chat_view.set_status(Some( + "Checking compatible content from external AI applications".to_string(), + )); + } else if let Some(notice) = tool_notice { + chat_view.set_status(Some(notice)); + } else if discovery_just_finished { + let (available, restricted) = external_command_counts(&snapshot); + let pending_conflicts = snapshot + .command_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + chat_view.set_status(Some(format!( + "External sources ready: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" + ))); + } + self.external_source_snapshot = Some(snapshot); + needs_redraw = true; + } + } + if external_source_closed { + external_source_rx = None; + } + + if chat_view.login_form_visible() { + self.refresh_account_panel_live(&mut chat_view); + if crate::account_sync::sync_in_flight() { + needs_redraw = true; + } + } + + let mut did_render_this_loop = false; + if needs_redraw && resize_redraw.can_render() { + terminal.draw(|frame| { + chat_view.render(frame, &chat_state); + })?; + needs_redraw = false; + did_render_this_loop = true; + } + + // 1.5. Execute pending MCP operations (after render so loading state is visible) + if resize_redraw.can_render() { + if let Some(op) = self.pending_mcp_op.take() { + if !did_render_this_loop { + terminal.draw(|frame| { + chat_view.render(frame, &chat_state); + })?; + } + match op { + PendingMcpOp::Toggle(server_id) => { + self.execute_mcp_toggle( + &server_id, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + PendingMcpOp::Add { name, config_json } => { + self.execute_mcp_add( + &name, + &config_json, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + PendingMcpOp::Delete(server_id) => { + self.execute_mcp_delete( + &server_id, + &mut chat_view, + &mut chat_state, + &rt_handle, + ); + } + } + needs_redraw = true; + } + } + + // 2. Process core events (non-blocking) + let mut events = Vec::with_capacity(20); + for _ in 0..20 { + match event_rx.try_recv() { + Ok(envelope) => events.push(envelope), + Err(error) => { + let Some(mut failure) = agent_event_stream_failure(error) else { + break; + }; + + // The adapter records the turn before DialogTurnStarted reaches the UI, + // so cancellation must not depend on ChatState having seen that event. + let agent = self.agent.clone(); + if let Err(cancel_error) = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.cancel_current_turn()) + }) { + failure = format!( + "{failure}; failed to cancel the active turn: {cancel_error}" + ); + } + mark_active_turn_failed(&mut chat_state, &failure); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("Error: {failure}"))); + tracing::error!("{failure}"); + fatal_event_stream_error = Some(failure); + break; + } + } + } + if fatal_event_stream_error.is_some() { + break; + } + for envelope in events { + let event = &envelope.event; + + if let AgenticEvent::SubagentSessionLinked { + session_id: subagent_session_id, + parent_session_id, + parent_tool_call_id, + .. + } = event + { + if parent_session_id == &session_id { + subagent_parent_tools + .insert(subagent_session_id.clone(), parent_tool_call_id.clone()); + } + continue; + } + + // Check if this is a subagent event that belongs to our session + if event.session_id() != Some(&session_id) { + // Check if this event was emitted by a subagent whose parent is in our session + if let Some(parent_tool_call_id) = event + .session_id() + .and_then(|event_session_id| subagent_parent_tools.get(event_session_id)) + { + // Forward subagent event to the parent Task tool for progress display + chat_state.handle_subagent_event(parent_tool_call_id, event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + continue; + } + + tracing::debug!("Processing core event: {:?}", event); + + match event { + AgenticEvent::DialogTurnStarted { + turn_id, + user_input, + .. + } => { + chat_state.handle_turn_started(turn_id, user_input); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + + AgenticEvent::TextChunk { turn_id, text, .. } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_text_chunk(text); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } else { + tracing::debug!( + "Ignoring TextChunk for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } + } + + AgenticEvent::ThinkingChunk { + turn_id, content, .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_thinking_chunk(content); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } else { + tracing::debug!( + "Ignoring ThinkingChunk for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } + } + + AgenticEvent::ToolEvent { + turn_id, + tool_event, + .. + } => { + if chat_state.current_turn_id() != Some(turn_id.as_str()) { + tracing::debug!( + "Ignoring ToolEvent for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + continue; + } + if let ToolEventData::ConfirmationNeeded { identity, .. } = tool_event { + if self + .runtime + .approval_controller() + .is_allowed(identity.effective_name()) + { + let agent = self.agent.clone(); + let tool_id = identity.tool_id.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.confirm_tool(&tool_id, None)) + }) { + Ok(()) => continue, + Err(error) => tracing::error!( + "Failed to confirm runtime-approved tool; showing the permission prompt again: {}", + error + ), + } + } + } + chat_state.handle_tool_event(tool_event); + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + + AgenticEvent::DialogTurnCompleted { + turn_id, + total_rounds, + total_tools, + .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_turn_completed(*total_rounds, *total_tools); + chat_view.invalidate_lines_cache(); + chat_view.set_status(None); + needs_redraw = true; + tracing::info!("Dialog turn completed"); + } else { + tracing::debug!( + "Ignoring DialogTurnCompleted for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } + } + + AgenticEvent::DialogTurnFailed { turn_id, error, .. } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_turn_failed(error); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("Error: {}", error))); + needs_redraw = true; + tracing::error!("Dialog turn failed: {}", error); + } else { + tracing::debug!( + "Ignoring DialogTurnFailed for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } + } + + AgenticEvent::DialogTurnCancelled { turn_id, .. } => { + let active_turn_id = chat_state.current_turn_id(); + if active_turn_id.is_none() || active_turn_id == Some(turn_id.as_str()) { + chat_state.handle_turn_cancelled(); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some("Cancelled".to_string())); + needs_redraw = true; + tracing::info!("Dialog turn cancelled"); + } else { + tracing::debug!( + "Ignoring DialogTurnCancelled for non-active turn: active={:?}, event={}", + chat_state.current_turn_id(), + turn_id + ); + } + } + + AgenticEvent::TokenUsageUpdated { + turn_id, + total_tokens, + .. + } => { + if chat_state.current_turn_id() == Some(turn_id.as_str()) { + chat_state.handle_token_usage(*total_tokens); + needs_redraw = true; + } + } + + AgenticEvent::SystemError { error, .. } => { + chat_state.add_system_message(format!("[System error: {}]", error)); + chat_view.invalidate_lines_cache(); + chat_view.set_status(Some(format!("System error: {}", error))); + needs_redraw = true; + tracing::error!("System error: {}", error); + } + + // Other events we don't need to handle in the UI + _ => {} + } + } + + // 3. Process terminal input + if let Some(events) = event_reader.read_event_batch(Duration::from_millis(16))? { + for event in events { + match event { + Event::Key(key) => { + if let Some(reason) = self.handle_key_event( + key, + &mut chat_view, + &mut chat_state, + &rt_handle, + )? { + Self::apply_exit_reason( + reason, + ChatEventContext { + this: self, + chat_view: &mut chat_view, + chat_state: &mut chat_state, + session_id: &mut session_id, + rt_handle: &rt_handle, + should_quit: &mut should_quit, + exit_reason: &mut exit_reason, + }, + ); + } + if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { + needs_redraw = true; + } + } + other => { + let outcome = Self::handle_non_key_event( + other, + ChatEventContext { + this: self, + chat_view: &mut chat_view, + chat_state: &mut chat_state, + session_id: &mut session_id, + rt_handle: &rt_handle, + should_quit: &mut should_quit, + exit_reason: &mut exit_reason, + }, + )?; + if outcome.request_redraw { + needs_redraw = true; + } + if outcome.resize_observed { + resize_redraw.observe(Instant::now()); + } + } + } + } + } + + // Only invalidate after the complete input batch has been drained. The + // next draw then uses Ratatui's current backend size instead of a stale + // dimension captured from an earlier resize event in the same burst. + if resize_redraw.take_ready(Instant::now()) { + chat_view.invalidate_lines_cache(); + needs_redraw = true; + } + } + + let terminal_restore_result = restore_terminal(terminal); + if let Some(failure) = fatal_event_stream_error { + if let Err(restore_error) = terminal_restore_result { + return Err(anyhow!( + "{failure}; failed to restore the terminal: {restore_error}" + )); + } + return Err(anyhow!(failure)); + } + terminal_restore_result?; + tracing::info!("Chat mode exited"); + + Ok(exit_reason) + } +} diff --git a/src/apps/cli/src/modes/chat/selection.rs b/src/apps/cli/src/modes/chat/selection.rs new file mode 100644 index 0000000000..2c64975faa --- /dev/null +++ b/src/apps/cli/src/modes/chat/selection.rs @@ -0,0 +1,497 @@ +enum ModelSelectionApplyOutcome { + SessionUpdateFailed(String), + Applied { + default_persist_error: Option, + }, +} + +fn apply_model_selection_feedback( + chat_state: &mut ChatState, + selected_display_name: &str, + selected_id: &str, + outcome: ModelSelectionApplyOutcome, +) { + match outcome { + ModelSelectionApplyOutcome::SessionUpdateFailed(error) => { + tracing::error!( + "Failed to switch model to {} ({}): {}", + selected_display_name, + selected_id, + error + ); + chat_state.add_system_message(format!( + "Current session model was not changed: {error}. Please retry." + )); + } + ModelSelectionApplyOutcome::Applied { + default_persist_error, + } => { + chat_state.current_model_name = selected_display_name.to_string(); + tracing::info!( + "Model switched to: {} ({})", + selected_display_name, + selected_id + ); + if let Some(error) = default_persist_error { + tracing::warn!( + "Current session model changed, but the future default could not be saved: {}", + error + ); + chat_state.add_system_message( + "Model switched for the current session, but the default for future sessions could not be saved. Check configuration storage and retry if needed." + .to_string(), + ); + } + } + } +} + +impl ChatMode { + fn logout(&self, chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle) { + let logged_in = + tokio::task::block_in_place(|| rt_handle.block_on(crate::account::is_logged_in())); + if !logged_in { + chat_state.add_system_message("Not logged in.".to_string()); + return; + } + match tokio::task::block_in_place(|| rt_handle.block_on(crate::account::logout())) { + Ok(()) => chat_state.add_system_message("Logged out.".to_string()), + Err(error) => chat_state.add_system_message(format!("Logout failed: {error}")), + } + } + + fn show_usage_report( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if chat_state.is_processing { + chat_view.set_status(Some( + "Wait until the session is idle before using /usage.".to_string(), + )); + return; + } + + let session_id = chat_state.core_session_id.clone(); + let workspace_path = chat_state + .workspace + .clone() + .or_else(|| self.workspace.clone()) + .or_else(|| Some(self.agent.workspace_path_string())); + let agent = self.agent.clone(); + + let report_result: Result = + tokio::task::block_in_place(|| { + let session_id = session_id.clone(); + let workspace_path = workspace_path.clone(); + let agent = agent.clone(); + rt_handle.block_on(async move { + let workspace_path = workspace_path + .filter(|path| !path.trim().is_empty()) + .ok_or_else(|| anyhow!("Workspace path is required for usage reports"))?; + + let report = agent + .generate_session_usage_report(SessionUsageReportRequest { + session_id: session_id.clone(), + workspace_path: Some(workspace_path), + remote_connection_id: None, + remote_ssh_host: None, + include_hidden_subagents: true, + }) + .await?; + + let markdown = render_usage_report_markdown(&report); + let generated_at = u64::try_from(report.generated_at).unwrap_or_default(); + let usage_report = serde_json::to_value(&report) + .map_err(|error| anyhow!("Failed to serialize usage report: {}", error))?; + let metadata = serde_json::json!({ + "localCommandKind": "usage_report", + "reportId": report.report_id.clone(), + "schemaVersion": report.schema_version, + "generatedAt": report.generated_at, + "modelVisible": false, + "usageReport": usage_report, + "usageReportStatus": "completed", + }); + + agent + .append_completed_local_command_turn( + &session_id, + markdown, + Some(format!("local-usage-{}", report.report_id)), + Some(generated_at), + Some(metadata), + ) + .await + .map_err(|error| anyhow!(error.to_string()))?; + + Ok(report) + }) + }); + + match report_result { + Ok(report) => { + let markdown = render_usage_report_markdown(&report); + chat_state.add_assistant_message(markdown); + chat_view.set_status(Some("Usage report added to conversation".to_string())); + } + Err(error) => { + chat_state + .add_system_message(format!("Failed to generate usage report: {}", error)); + } + } + } + + fn list_available_themes(&self) -> Vec { + let mut themes = Vec::new(); + for id in builtin_theme_ids() { + themes.push(ThemeItem { id }); + } + + themes.sort_by_cached_key(|theme| theme.id.to_ascii_lowercase()); + themes.dedup_by(|a, b| a.id == b.id); + themes + } + + fn resolve_configured_theme( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + ) -> Theme { + self.resolve_theme_by_id(base, appearance, scheme, self.config.ui.theme_id.trim()) + } + + fn resolve_theme_by_id( + &self, + base: Theme, + appearance: Appearance, + scheme: EffectiveColorScheme, + id: &str, + ) -> Theme { + if scheme == EffectiveColorScheme::Monochrome { + return Theme::monochrome(); + } + + if id.is_empty() { + return base; + } + + if let Some(json) = builtin_theme_json(id) { + return base + .apply_opencode_theme_json(json, appearance) + .unwrap_or(base) + .with_effective_scheme(scheme); + } + + base + } + + fn preview_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); + chat_view.set_theme(resolved); + chat_view.set_status(Some(format!( + "Preview theme: {} (Enter apply, Esc cancel)", + theme.id + ))); + } + + fn apply_theme_selection(&mut self, theme: &ThemeItem, chat_view: &mut ChatView) { + let appearance = resolve_appearance(&self.config.ui.theme); + let scheme = resolve_effective_color_scheme(&self.config.ui.color_scheme); + let base_is_light = appearance.is_light(); + let base = match (base_is_light, scheme) { + (_, EffectiveColorScheme::Monochrome) => Theme::monochrome(), + (true, EffectiveColorScheme::Ansi16) => Theme::light_ansi16(), + (true, EffectiveColorScheme::Truecolor) => Theme::light(), + (false, EffectiveColorScheme::Ansi16) => Theme::dark_ansi16(), + (false, EffectiveColorScheme::Truecolor) => Theme::dark(), + }; + + self.config.ui.theme_id = theme.id.clone(); + if let Err(e) = self.config.save() { + chat_view.set_status(Some(format!("Failed to save config: {}", e))); + } + + let resolved = self.resolve_theme_by_id(base, appearance, scheme, theme.id.trim()); + chat_view.set_theme(resolved); + chat_view.set_status(Some(format!("Theme set to: {}", theme.id))); + } + + fn get_mode_agents(&self, rt_handle: &tokio::runtime::Handle) -> Vec { + let registry = get_agent_registry(); + let modes = tokio::task::block_in_place(|| rt_handle.block_on(registry.get_modes_info())); + modes + } + + fn cycle_agent( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + self.switch_agent_by_offset(1, chat_view, chat_state, rt_handle); + } + + fn cycle_agent_reverse( + &mut self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + self.switch_agent_by_offset(-1, chat_view, chat_state, rt_handle); + } + + fn switch_agent_by_offset( + &mut self, + offset: isize, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let modes = self.get_mode_agents(rt_handle); + if modes.len() <= 1 { + return; + } + + let current_idx = modes + .iter() + .position(|m| m.id == self.agent_type) + .unwrap_or(0); + + let len = modes.len() as isize; + let next_idx = ((current_idx as isize + offset) % len + len) % len; + let next = &modes[next_idx as usize]; + + self.agent_type = next.id.clone(); + chat_state.agent_type = next.id.clone(); + } + + /// Load current model name from global config for display + fn load_current_model_name( + &self, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let result: Option = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = GlobalConfigManager::get_service().await.ok()?; + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; + + fn provider_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + let raw_name = model.name.trim(); + let model_name = model.model_name.trim(); + if !raw_name.is_empty() && !model_name.is_empty() { + let dashed_suffix = format!(" - {}", model_name); + let slash_suffix = format!("/{}", model_name); + if let Some(provider) = raw_name.strip_suffix(&dashed_suffix) { + return provider.trim().to_string(); + } + if let Some(provider) = raw_name.strip_suffix(&slash_suffix) { + return provider.trim().to_string(); + } + } + if raw_name.is_empty() { + model.provider.clone() + } else { + raw_name.to_string() + } + } + + fn model_display_name( + model: &bitfun_core::service::config::AIModelConfig, + ) -> String { + format!("{} / {}", model.model_name, provider_display_name(model)) + } + + let model_name = models + .iter() + .find(|model| model.id == model_id) + .map(model_display_name); + + model_name + }) + }); + + if let Some(name) = result { + chat_state.current_model_name = name; + } + } + + /// Show model selector popup with all available models + fn show_model_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + tracing::error!("Failed to get config service: {}", e); + return None; + } + }; + + let models: Vec = + config_service.get_ai_models().await.ok()?; + let global_config: bitfun_core::service::config::GlobalConfig = + config_service.get_config(None).await.ok()?; + + let current_model_id = + crate::model_selection::resolve_mode_model_id(&global_config.ai); + + // Convert to ModelItem list (only enabled models) + let model_items: Vec = models + .into_iter() + .filter(|m| m.enabled) + .map(|m| ModelItem { + id: m.id, + name: m.name, + provider: m.provider, + model_name: m.model_name, + }) + .collect(); + + Some((model_items, current_model_id)) + }) + }); + + match result { + Some((models, current_id)) if !models.is_empty() => { + chat_view.show_model_selector(models, current_id); + } + _ => { + chat_state.add_system_message( + "No available models found. Please configure models first.".to_string(), + ); + } + } + } + + /// Apply the current-session model and best-effort future-session default. + fn apply_model_selection( + &self, + selected: &ModelItem, + _chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let selected_id = selected.id.clone(); + let selected_display_name = format!("{} / {}", selected.model_name, selected.name); + let session_id = chat_state.core_session_id.clone(); + + let outcome = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + if let Err(e) = self + .agent + .update_session_model(&session_id, &selected_id) + .await + { + return ModelSelectionApplyOutcome::SessionUpdateFailed(e.to_string()); + } + + let config_service = match GlobalConfigManager::get_service().await { + Ok(s) => s, + Err(e) => { + return ModelSelectionApplyOutcome::Applied { + default_persist_error: Some(e.to_string()), + }; + } + }; + + if let Err(e) = config_service + .set_config("ai.agent_model_defaults.mode", &selected_id) + .await + { + return ModelSelectionApplyOutcome::Applied { + default_persist_error: Some(e.to_string()), + }; + } + + ModelSelectionApplyOutcome::Applied { + default_persist_error: None, + } + }) + }); + + apply_model_selection_feedback( + chat_state, + &selected_display_name, + &selected_id, + outcome, + ); + } + + /// Show agent selector popup with all available agent modes + fn show_agent_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let modes = self.get_mode_agents(rt_handle); + if modes.is_empty() { + chat_state.add_system_message("No mode agents available".to_string()); + return; + } + + let agent_items: Vec = modes + .into_iter() + .map(|m| AgentItem { + id: m.id, + description: m.description, + }) + .collect(); + + chat_view.show_agent_selector(agent_items, Some(self.agent_type.clone())); + } + + /// Apply agent selection: switch agent type + fn apply_agent_selection(&mut self, selected: &AgentItem, chat_state: &mut ChatState) { + if selected.id == self.agent_type { + return; + } + self.agent_type = selected.id.clone(); + chat_state.agent_type = selected.id.clone(); + tracing::info!("Switched to agent: {}", selected.id); + + if selected.id == "HarmonyOSDev" { + let deveco_home = std::env::var("DEVECO_HOME").ok(); + let missing = deveco_home + .as_deref() + .map(|s| s.trim().is_empty()) + .unwrap_or(true); + if missing { + chat_state.add_system_message( + "HarmonyOSDev tip: HmosCompilation requires DEVECO_HOME (DevEco Studio install path). If compilation fails, set DEVECO_HOME and restart the terminal." + .to_string(), + ); + } + } + } + + // ============ MCP management ============ + +} diff --git a/src/apps/cli/src/modes/chat/sessions.rs b/src/apps/cli/src/modes/chat/sessions.rs new file mode 100644 index 0000000000..67456ece73 --- /dev/null +++ b/src/apps/cli/src/modes/chat/sessions.rs @@ -0,0 +1,210 @@ +impl ChatMode { + /// Switch to a different session: restore it from core, reload messages, update state + fn switch_to_session( + &mut self, + new_session_id: &str, + session_id: &mut String, + chat_state: &mut ChatState, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) -> Result<()> { + let agent = self.agent.clone(); + let sid = new_session_id.to_string(); + + let (new_state, restored_agent_type) = tokio::task::block_in_place(|| { + rt_handle.block_on(async { + let (session_summary, effective_workspace_path) = + agent.restore_session_in_current_workspace(&sid).await?; + let restored_agent_type = session_summary.agent_type.clone(); + let effective_workspace = + Some(effective_workspace_path.to_string_lossy().to_string()); + + // Load historical messages through the runtime transcript contract. + let transcript = agent.get_transcript(&sid).await.unwrap_or_else(|_| { + bitfun_agent_runtime::sdk::SessionTranscript { + session_id: sid.clone(), + messages: Vec::new(), + } + }); + + let state = ChatState::from_session_transcript( + sid.clone(), + session_summary.session_name, + restored_agent_type.clone(), + effective_workspace, + &transcript, + ); + + Ok::<_, anyhow::Error>((state, restored_agent_type)) + }) + })?; + + // Update session state + *session_id = new_session_id.to_string(); + *chat_state = new_state; + self.agent_type = restored_agent_type; + self.workspace = chat_state.workspace.clone(); + + // Reload model name + self.load_current_model_name(chat_state, rt_handle); + + // Reset view state + chat_view.scroll_to_bottom(); + chat_view.set_status(Some(format!("Switched to session: {}", new_session_id))); + + Ok(()) + } + + /// Create a new session: reset state and start fresh + fn create_new_session( + &mut self, + session_id: &mut String, + chat_state: &mut ChatState, + chat_view: &mut ChatView, + rt_handle: &tokio::runtime::Handle, + ) -> Result<()> { + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + let workspace = self.workspace.clone(); + + let new_session_id = tokio::task::block_in_place(|| { + rt_handle.block_on(agent.create_new_session(&agent_type)) + })?; + + let new_state = ChatState::new( + new_session_id.clone(), + "CLI Session".to_string(), + agent_type, + workspace, + ); + + *session_id = new_session_id; + *chat_state = new_state; + self.workspace = chat_state.workspace.clone(); + + // Reload model name + self.load_current_model_name(chat_state, rt_handle); + + // Reset view state + chat_view.clear_screen(); + chat_view.scroll_to_bottom(); + chat_view.set_status(Some("New session created".to_string())); + + Ok(()) + } + + /// Show skill list/configuration menu. + /// Send a message to the agent programmatically (used by slash commands like /init) + fn send_message_to_agent( + &self, + message: String, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + if chat_state.is_processing { + chat_state.add_system_message("Already processing, please wait.".to_string()); + return; + } + + let display_name = agent_display_name(&self.agent_type); + chat_view.set_status(Some(format!("{} is thinking...", display_name))); + + let agent = self.agent.clone(); + let agent_type = self.agent_type.clone(); + match tokio::task::block_in_place(|| { + rt_handle.block_on(agent.send_message(message, &agent_type)) + }) { + Ok(turn_id) => { + tracing::info!("Started turn: {}", turn_id); + } + Err(e) => { + tracing::error!("Failed to send message: {}", e); + chat_view.set_status(Some(format!("Error: {}", e))); + } + } + } + + /// Show session selector popup with all available sessions + fn show_session_selector( + &self, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let agent = self.agent.clone(); + let current_session_id = chat_state.core_session_id.clone(); + + let sessions = tokio::task::block_in_place(|| { + rt_handle.block_on(async { agent.list_sessions().await.unwrap_or_default() }) + }); + + if sessions.is_empty() { + chat_state.add_system_message("No sessions found.".to_string()); + return; + } + + let session_items: Vec = sessions + .into_iter() + .map(|s| { + let last_activity = { + let last_activity = + std::time::UNIX_EPOCH + Duration::from_millis(s.last_active_at_ms); + let elapsed = last_activity.elapsed().unwrap_or_default(); + if elapsed.as_secs() < 60 { + "just now".to_string() + } else if elapsed.as_secs() < 3600 { + format!("{}m ago", elapsed.as_secs() / 60) + } else if elapsed.as_secs() < 86400 { + format!("{}h ago", elapsed.as_secs() / 3600) + } else { + format!("{}d ago", elapsed.as_secs() / 86400) + } + }; + SessionItem { + session_id: s.session_id, + session_name: s.session_name, + last_activity, + workspace: self.workspace.clone(), + } + }) + .collect(); + + chat_view.show_session_selector(session_items, Some(current_session_id)); + } + + /// Handle session deletion from the session selector + fn handle_session_delete( + &self, + item: &SessionItem, + chat_view: &mut ChatView, + chat_state: &mut ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + // Prevent deleting the currently active session + if item.session_id == chat_state.core_session_id { + chat_view.set_status(Some("Cannot delete the active session".to_string())); + return; + } + + let agent = self.agent.clone(); + let sid = item.session_id.clone(); + + let result = tokio::task::block_in_place(|| { + rt_handle.block_on(async { agent.delete_session(&sid).await }) + }); + + match result { + Ok(()) => { + chat_view.session_selector_remove_item(&item.session_id); + chat_view.set_status(Some(format!("Session deleted: {}", item.session_name))); + tracing::info!("Deleted session: {}", item.session_id); + } + Err(e) => { + chat_view.set_status(Some(format!("Failed to delete session: {}", e))); + tracing::error!("Failed to delete session: {}", e); + } + } + } + +} diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs new file mode 100644 index 0000000000..4d48a011a8 --- /dev/null +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -0,0 +1,612 @@ +#[cfg(test)] +mod tests { + use tokio::sync::broadcast::error::TryRecvError; + + use super::{ + agent_event_stream_failure, builtin_command_reconfirmation, command_route, + external_command_projections, external_tool_mutation_result_label, + external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, + apply_model_selection_feedback, mark_active_turn_failed, parse_command_token, + parse_external_tool_review_action, CommandQualifier, CommandRoute, + ExternalSourceConflictPreferences, ExternalToolReviewAction, ModelSelectionApplyOutcome, + }; + use crate::actions::{ActionState, ResolvedKeymap}; + use crate::chat_state::ChatState; + use crate::config::ShortcutsConfig; + use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; + use bitfun_core::external_sources::{ + ExternalSourceCatalogSnapshot, ExternalToolActivationState, + }; + use std::collections::{BTreeMap, BTreeSet}; + + fn external_command( + name: &str, + selected_candidate_id: Option<&str>, + ) -> ExternalCommandProjection { + ExternalCommandProjection { + action_id: format!("external-command:{name}"), + command_name: name.to_string(), + invocation_alias: format!("/{name}"), + candidate_id: format!("external:{name}"), + content_version: "v1".to_string(), + description: "External command".to_string(), + restricted: false, + provider_conflict_key: None, + native_collision: Some(NativeCommandCollisionProjection { + native_action_id: name.to_string(), + native_candidate_id: format!("bitfun.cli:{name}"), + external_candidate_id: format!("external:{name}"), + conflict_key: "conflict-v1".to_string(), + selected_candidate_id: selected_candidate_id.map(str::to_string), + }), + } + } + + fn external_tool_review_snapshot() -> ExternalSourceCatalogSnapshot { + serde_json::from_value(serde_json::json!({ + "generation": 3, + "discoveryPending": false, + "sources": [{ + "stableKey": "opencode-tools-project", + "record": { + "key": { "providerId": "opencode.tools", "sourceId": "project" }, + "ecosystemId": "opencode", + "displayName": "OpenCode project tools", + "sourceKind": "tools", + "scope": "project", + "location": "D:/repo/.opencode/tools", + "executionDomainId": "local:D:/repo", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + }], + "commands": [], + "tools": [{ + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "review.js" + }, + "exportId": "default" + }, + "name": "review", + "descriptionPreview": "Review a change", + "modulePath": "D:/repo/.opencode/tools/review.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["file_system", "network", "environment", "process"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "activation": { "state": "approval_required" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "weather.js" + }, + "exportId": "default" + }, + "name": "weather", + "descriptionPreview": "Read weather", + "modulePath": "D:/repo/.opencode/tools/weather.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["network"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v2", + "decisionKey": "decision-v2", + "activation": { "state": "disabled" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "deploy.js" + }, + "exportId": "default" + }, + "name": "deploy", + "descriptionPreview": "Deploy a build", + "modulePath": "D:/repo/.opencode/tools/deploy.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["process"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v3", + "decisionKey": "decision-v3", + "activation": { "state": "active" } + }, { + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "broken.ts" + }, + "exportId": "default" + }, + "name": "broken", + "descriptionPreview": "Broken tool", + "modulePath": "D:/repo/.opencode/tools/broken.ts", + "workingDirectory": "D:/repo", + "runtimeKind": "type_script", + "capabilities": ["file_system"], + "contentVersion": "content-v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v4", + "decisionKey": "decision-v4", + "activation": { + "state": "load_failed", + "reason": "PR2 worker could not import the module" + } + }], + "toolApprovalRequests": [{ + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "targetId": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "review.js" + }, + "sourceDisplayName": "OpenCode project tools", + "sourceScope": "project", + "sourceLocation": "D:/repo/.opencode/tools/review.js", + "workingDirectory": "D:/repo", + "runtimeKind": "java_script", + "capabilities": ["file_system", "network", "environment", "process"], + "contentVersion": "content-v1", + "toolNames": ["review"] + }], + "toolConflicts": [{ + "conflictKey": "conflict-v1", + "toolName": "review", + "candidates": [{ + "candidateId": "bitfun:review", + "displayName": "BitFun review", + "kind": "built_in", + "providerId": "bitfun", + "contentVersion": "builtin-v1" + }, { + "candidateId": "external:review", + "displayName": "OpenCode review", + "kind": "external", + "providerId": "opencode.tools", + "contentVersion": "content-v1", + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "sourceLocation": "D:/repo/.opencode/tools/review.js" + }] + }], + "diagnostics": [{ + "severity": "warning", + "code": "opencode.tool.directory_read_failed", + "message": "PR2 worker could not read one tool directory", + "source": { "providerId": "opencode.tools", "sourceId": "project" } + }] + })) + .unwrap() + } + + #[test] + fn external_tool_review_summary_discloses_execution_boundary_and_commands() { + let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); + + assert!(summary.contains("No external code ran during discovery")); + assert!(summary.contains("filesystem, network, process, environment variables")); + assert!(summary.contains("inherited environment variables")); + assert!(summary.contains("full descendant-process cleanup")); + assert!(summary.contains("/external-tools enable 1")); + assert!(summary.contains("/external-tools choose 1 2")); + assert!(summary.contains("D:/repo/.opencode/tools/review.js")); + assert!(summary.contains("Source root: D:/repo/.opencode/tools")); + assert!(summary.contains("Scope: project")); + assert!(summary.contains("Execution domain: local:D:/repo")); + assert!(summary.contains("disabled")); + assert!(summary.contains("active")); + assert!(summary.contains("loaded successfully")); + assert!(summary.contains("load failed")); + assert!(summary.contains("D:/repo/.opencode/tools/broken.ts")); + assert!(summary.contains("Diagnostics")); + assert!(summary.contains("opencode.tool.directory_read_failed")); + assert!(!summary.contains("PR2")); + } + + #[test] + fn external_tool_review_commands_resolve_indices_to_stable_keys() { + let snapshot = external_tool_review_snapshot(); + + assert_eq!( + parse_external_tool_review_action("enable 2", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v2".to_string(), + decision_key: "decision-v2".to_string(), + approved: true, + } + ); + assert_eq!( + parse_external_tool_review_action("disable 3", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v3".to_string(), + decision_key: "decision-v3".to_string(), + approved: false, + } + ); + assert_eq!( + parse_external_tool_review_action("disable 4", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v4".to_string(), + decision_key: "decision-v4".to_string(), + approved: false, + } + ); + assert_eq!( + parse_external_tool_review_action("choose 1 2", Some(&snapshot), None).unwrap(), + ExternalToolReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "external:review".to_string(), + } + ); + assert!(parse_external_tool_review_action("enable 3", Some(&snapshot), None).is_err()); + } + + #[test] + fn external_tool_review_commands_keep_the_indices_from_the_displayed_review() { + let reviewed = external_tool_review_snapshot(); + let mut current = reviewed.clone(); + current.tools.swap(0, 1); + + assert_eq!( + parse_external_tool_review_action("enable 2", Some(¤t), Some(&reviewed)).unwrap(), + ExternalToolReviewAction::Decide { + approval_key: "approval-v2".to_string(), + decision_key: "decision-v2".to_string(), + approved: true, + } + ); + } + + #[test] + fn external_tool_enable_result_reports_the_returned_activation() { + let mut snapshot = external_tool_review_snapshot(); + snapshot.tools[0].activation = ExternalToolActivationState::LoadFailed { + reason: "module import failed".to_string(), + }; + let action = ExternalToolReviewAction::Decide { + approval_key: "approval-v1".to_string(), + decision_key: "decision-v1".to_string(), + approved: true, + }; + + assert_eq!( + external_tool_mutation_result_label(&action, &snapshot), + "External tool approved, but loading failed" + ); + } + + #[test] + fn external_tool_notice_key_changes_for_pending_decisions_or_diagnostics() { + let snapshot = external_tool_review_snapshot(); + let key = external_tool_pending_notice_key(&snapshot).unwrap(); + let mut generation_only = snapshot.clone(); + generation_only.generation += 1; + assert_eq!( + external_tool_pending_notice_key(&generation_only), + Some(key.clone()) + ); + + generation_only.tool_approval_requests[0].decision_key = "decision-v2".to_string(); + assert_ne!( + external_tool_pending_notice_key(&generation_only), + Some(key.clone()) + ); + + let mut diagnostic_change = snapshot; + diagnostic_change.diagnostics[0].message = "different failure".to_string(); + assert_ne!( + external_tool_pending_notice_key(&diagnostic_change), + Some(key) + ); + } + + #[test] + fn external_tool_mutation_result_does_not_overwrite_a_newer_catalog_generation() { + let incoming = external_tool_review_snapshot(); + let mut current = incoming.clone(); + current.generation += 1; + + assert!(external_tool_result_is_stale(Some(¤t), &incoming)); + assert!(!external_tool_result_is_stale(Some(&incoming), ¤t)); + assert!(!external_tool_result_is_stale(None, &incoming)); + } + + #[test] + fn explicit_builtin_never_falls_through_to_an_external_command() { + let external = external_command("review", None); + assert_eq!( + command_route( + CommandQualifier::Builtin, + false, + Some(&external), + false, + false, + ), + CommandRoute::UnknownBuiltin + ); + } + + #[test] + fn command_qualifiers_are_ascii_case_insensitive() { + assert_eq!( + parse_command_token("/BUILTIN:help"), + (CommandQualifier::Builtin, "help") + ); + assert_eq!( + parse_command_token("/External:review"), + (CommandQualifier::External, "review") + ); + } + + #[test] + fn unresolved_provider_conflicts_expose_explicit_cli_choices() { + let snapshot: ExternalSourceCatalogSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [ + { + "stableKey": "first", + "record": { + "key": { "providerId": "first.commands", "sourceId": "global" }, + "ecosystemId": "first", + "displayName": "First commands", + "sourceKind": "prompt_commands", + "scope": "user_global", + "location": "/first", + "executionDomainId": "local-user", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + }, + { + "stableKey": "second", + "record": { + "key": { "providerId": "second.commands", "sourceId": "global" }, + "ecosystemId": "second", + "displayName": "Second commands", + "sourceKind": "prompt_commands", + "scope": "user_global", + "location": "/second", + "executionDomainId": "local-user", + "health": "available", + "contentVersion": "source-v1" + }, + "lifecycle": "available" + } + ], + "commands": [], + "commandConflicts": [{ + "conflictKey": "provider-conflict-v1", + "commandName": "review", + "candidates": [ + { + "candidateId": "first-candidate", + "source": { "providerId": "first.commands", "sourceId": "global" }, + "sourceDisplayName": "First commands", + "ecosystemId": "first", + "contentVersion": "command-v1", + "commandDescription": "First review", + "sourceScope": "user_global", + "sourceLocation": "/first", + "availability": { "state": "available" } + }, + { + "candidateId": "second-candidate", + "source": { "providerId": "second.commands", "sourceId": "global" }, + "sourceDisplayName": "Second commands", + "ecosystemId": "second", + "contentVersion": "command-v1", + "commandDescription": "Second review", + "sourceScope": "user_global", + "sourceLocation": "/second", + "availability": { "state": "available" } + } + ] + }] + })) + .unwrap(); + + let projections = external_command_projections(&snapshot, &BTreeMap::new()); + + assert_eq!(projections.len(), 2); + assert!(projections.iter().all(|projection| { + projection.provider_conflict_key.as_deref() == Some("provider-conflict-v1") + })); + assert!(projections + .iter() + .any(|projection| projection.invocation_alias == "/external:first.commands:review")); + assert!(projections + .iter() + .any(|projection| projection.invocation_alias == "/external:second.commands:review")); + } + + #[test] + fn native_collision_requires_one_choice_and_then_reuses_it() { + let unresolved = external_command("help", None); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&unresolved), + false, + false, + ), + CommandRoute::AskForCollisionChoice + ); + let selected = external_command("help", Some("external:help")); + assert_eq!( + command_route( + CommandQualifier::Unqualified, + true, + Some(&selected), + false, + false, + ), + CommandRoute::External + ); + } + + #[test] + fn discovery_pending_requires_an_explicit_command_qualifier() { + assert_eq!( + command_route(CommandQualifier::Unqualified, true, None, true, false,), + CommandRoute::WaitForDiscovery + ); + assert_eq!( + command_route(CommandQualifier::Builtin, true, None, true, false), + CommandRoute::Builtin + ); + } + + #[test] + fn removed_external_candidate_requires_builtin_reconfirmation() { + assert_eq!( + command_route(CommandQualifier::Unqualified, true, None, false, true,), + CommandRoute::AskForCollisionChoice + ); + assert_eq!( + command_route(CommandQualifier::Builtin, true, None, false, true), + CommandRoute::Builtin + ); + } + + #[test] + fn persisted_collision_history_detects_a_removed_external_candidate() { + let action = + crate::actions::action_for_alias("/help", crate::actions::ActionContext::Chat).unwrap(); + let mut preferences = ExternalSourceConflictPreferences { + choices: BTreeMap::new(), + lineage_current_keys: BTreeMap::new(), + conflicted_candidate_ids: BTreeSet::from([ + "bitfun.cli:help".to_string(), + "external:help".to_string(), + ]), + }; + + let pending = builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); + assert!(!pending.confirmed); + + preferences + .choices + .insert(pending.conflict_key.clone(), pending.candidate_id.clone()); + let confirmed = + builtin_command_reconfirmation(action.id, action.name, &preferences).unwrap(); + assert!(confirmed.confirmed); + } + + #[test] + fn agent_event_stream_failure_ignores_empty_queue() { + assert_eq!(agent_event_stream_failure(TryRecvError::Empty), None); + } + + #[test] + fn agent_event_stream_failure_treats_lagged_and_closed_as_fatal() { + let lagged = agent_event_stream_failure(TryRecvError::Lagged(7)) + .expect("lagged stream must be fatal"); + assert!(lagged.contains("lagged by 7 events")); + assert!(lagged.contains("can no longer be trusted")); + + let closed = + agent_event_stream_failure(TryRecvError::Closed).expect("closed stream must be fatal"); + assert!(closed.contains("closed")); + assert!(closed.contains("can no longer be trusted")); + } + + #[test] + fn agent_event_stream_failure_marks_active_turn_failed() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.handle_turn_started("turn", "hello"); + + assert!(mark_active_turn_failed( + &mut state, + "Agent event stream closed; chat state can no longer be trusted" + )); + assert_eq!(state.current_turn_id(), None); + assert!(!state.is_processing); + } + + #[test] + fn model_selection_keeps_the_applied_session_model_when_default_persistence_fails() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_name = "Old model".to_string(); + + apply_model_selection_feedback( + &mut state, + "New model / Provider", + "new-model-id", + ModelSelectionApplyOutcome::Applied { + default_persist_error: Some("config storage unavailable".to_string()), + }, + ); + + assert_eq!(state.current_model_name, "New model / Provider"); + let notice = state.messages.last().expect("partial-success notice"); + let crate::chat_state::FlowItem::Text { content, .. } = ¬ice.flow_items[0] else { + panic!("partial-success notice must be text"); + }; + assert!(content.contains("current session")); + assert!(content.contains("future sessions")); + } + + #[test] + fn model_selection_reports_when_the_current_session_update_fails() { + let mut state = ChatState::new( + "session".to_string(), + "Session".to_string(), + "agentic".to_string(), + Some("D:/workspace/current".to_string()), + ); + state.current_model_name = "Old model".to_string(); + + apply_model_selection_feedback( + &mut state, + "New model / Provider", + "new-model-id", + ModelSelectionApplyOutcome::SessionUpdateFailed("session unavailable".to_string()), + ); + + assert_eq!(state.current_model_name, "Old model"); + let notice = state.messages.last().expect("failure notice"); + let crate::chat_state::FlowItem::Text { content, .. } = ¬ice.flow_items[0] else { + panic!("failure notice must be text"); + }; + assert!(content.contains("was not changed")); + assert!(content.contains("retry")); + } + + #[test] + fn shortcut_registry_contract_help_uses_resolved_keymap() { + let keymap = ResolvedKeymap::new(&ShortcutsConfig::default()); + + let help = keymap.help_text(ActionState::chat(false, false)); + assert!(help.contains("Ctrl+P")); + assert!(help.contains("Command Palette")); + } +} diff --git a/src/apps/cli/src/peer_host/commands/session.rs b/src/apps/cli/src/peer_host/commands/session.rs index 6bbcd3510c..6bcdb3605b 100644 --- a/src/apps/cli/src/peer_host/commands/session.rs +++ b/src/apps/cli/src/peer_host/commands/session.rs @@ -8,7 +8,9 @@ use serde_json::{json, Value}; use bitfun_core::agentic::core::{Session, SessionConfig}; use bitfun_core::agentic::get_agent_registry; use bitfun_core::service::session::SessionStatus; -use bitfun_runtime_ports::{AgentSessionDeleteRequest, SessionStoragePathRequest}; +use bitfun_runtime_ports::{ + AgentSessionDeleteRequest, AgentSessionModelUpdateRequest, SessionStoragePathRequest, +}; use crate::peer_host::args::{get_string, optional_bool, optional_string, request_value}; use crate::peer_host::state::PeerHostState; @@ -360,10 +362,13 @@ pub(crate) async fn update_session_model( let session_id = validated_session_id(request)?; let model_name = get_string(request, "modelName")?; state - .compatibility - .update_session_model(&session_id, &model_name) + .agent_runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id, + model_id: model_name, + }) .await - .map_err(|e| format!("Failed to update session model: {e}"))?; + .map_err(|error| format!("Failed to update session model: {}", error.into_message()))?; Ok(Value::Null) } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 77921c0d26..baa64a33ba 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -12,8 +12,9 @@ use crate::api::session_storage_path::desktop_effective_session_storage_path; use crate::runtime::DesktopRuntimeContext; use crate::startup_trace::DesktopStartupTrace; use bitfun_agent_runtime::sdk::{ - AgentDialogTurnRequest, AgentInputAttachment, AgentSubmissionSource, - AgentToolConfirmationRequest, AgentToolRejectionRequest, AgentTurnCancellationRequest, + AgentDialogTurnRequest, AgentInputAttachment, AgentSessionModelUpdateRequest, + AgentSubmissionSource, AgentToolConfirmationRequest, AgentToolRejectionRequest, + AgentTurnCancellationRequest, }; use bitfun_core::agentic::agents::AgentSource; use bitfun_core::agentic::coordination::{ @@ -887,13 +888,17 @@ pub async fn create_session( #[tauri::command] pub async fn update_session_model( - coordinator: State<'_, Arc>, + runtime: State<'_, DesktopRuntimeContext>, request: UpdateSessionModelRequest, ) -> Result<(), String> { - coordinator - .update_session_model(&request.session_id, &request.model_name) + runtime + .agent_runtime() + .update_session_model(AgentSessionModelUpdateRequest { + session_id: request.session_id, + model_id: request.model_name, + }) .await - .map_err(|e| format!("Failed to update session model: {}", e)) + .map_err(|error| format!("Failed to update session model: {}", error.into_message())) } #[tauri::command] diff --git a/src/apps/desktop/src/runtime/mod.rs b/src/apps/desktop/src/runtime/mod.rs index 5db2da7700..ed81f548dd 100644 --- a/src/apps/desktop/src/runtime/mod.rs +++ b/src/apps/desktop/src/runtime/mod.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use bitfun_agent_runtime::sdk::{ AgentDialogTurnPort, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, - AgentSubmissionPort, AgentTurnCancellationPort, RuntimeBuildError, + AgentSessionModelPort, AgentSubmissionPort, AgentTurnCancellationPort, RuntimeBuildError, }; use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; @@ -22,11 +22,13 @@ impl DesktopRuntimeContext { scheduler: Arc, ) -> Result { let submission: Arc = coordinator.clone(); + let session_model: Arc = coordinator.clone(); let interaction_response: Arc = coordinator; let dialog_turn: Arc = scheduler.clone(); let cancellation: Arc = scheduler; let agent_runtime = AgentRuntimeBuilder::new() .with_submission_port(submission) + .with_session_model_port(session_model) .with_dialog_turn_port(dialog_turn) .with_cancellation_port(cancellation) .with_interaction_response_port(interaction_response) @@ -57,6 +59,7 @@ mod tests { assert!(runtime_source.contains("with_dialog_turn_port")); assert!(runtime_source.contains("with_cancellation_port")); assert!(runtime_source.contains("with_interaction_response_port")); + assert!(runtime_source.contains("with_session_model_port")); } #[test] diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index dbf4824863..1911de52f5 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -7968,6 +7968,18 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato } } +#[async_trait::async_trait] +impl bitfun_runtime_ports::AgentSessionModelPort for ConversationCoordinator { + async fn update_session_model( + &self, + request: bitfun_runtime_ports::AgentSessionModelUpdateRequest, + ) -> bitfun_runtime_ports::PortResult<()> { + self.update_session_model(&request.session_id, &request.model_id) + .await + .map_err(runtime_port_error_preserving_message) + } +} + #[async_trait::async_trait] impl bitfun_agent_runtime::sdk::AgentSessionRestorePort for ConversationCoordinator { async fn restore_session( @@ -8469,6 +8481,78 @@ mod tests { format!("Not found: Tool task not found: {missing_tool_id}") ); } + + #[tokio::test] + async fn session_model_port_preserves_core_not_found_errors() { + use bitfun_agent_runtime::sdk::{AgentSessionModelPort, AgentSessionModelUpdateRequest}; + + let (coordinator, _) = test_coordinator(); + let error = AgentSessionModelPort::update_session_model( + &coordinator, + AgentSessionModelUpdateRequest { + session_id: "missing-session".to_string(), + model_id: "auto".to_string(), + }, + ) + .await + .expect_err("missing session must remain a typed not-found error"); + + assert_eq!(error.kind, bitfun_runtime_ports::PortErrorKind::NotFound); + assert!(error.message.contains("missing-session")); + } + + #[tokio::test] + async fn session_model_runtime_updates_the_real_core_session() { + use bitfun_agent_runtime::sdk::{AgentRuntimeBuilder, AgentSessionModelUpdateRequest}; + + let (coordinator, session_manager) = test_coordinator(); + let coordinator = Arc::new(coordinator); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-session-model-runtime-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let workspace_path_string = workspace_path.to_string_lossy().into_owned(); + let session = TEST_AGENT_MODEL_DEFAULTS + .scope( + AgentModelDefaultsConfig::default(), + coordinator.create_session_with_workspace( + None, + "Runtime model update".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path_string.clone()), + model_id: Some("primary".to_string()), + ..Default::default() + }, + workspace_path_string, + ), + ) + .await + .expect("real Core session should be created"); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(coordinator.clone()) + .with_session_model_port(coordinator) + .build() + .expect("assembled agent runtime"); + + runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: session.session_id.clone(), + model_id: " default ".to_string(), + }) + .await + .expect("runtime model port should update the Core owner"); + + assert_eq!( + session_manager + .get_session(&session.session_id) + .and_then(|session| session.config.model_id.clone()) + .as_deref(), + Some("auto") + ); + let _ = std::fs::remove_dir_all(workspace_path); + } use tokio::sync::RwLock as TokioRwLock; fn test_coordinator_with_max_active_sessions( diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index bc34324f48..bec6052d88 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -6023,6 +6023,39 @@ mod tests { ) } + #[tokio::test] + async fn session_model_update_is_restored_from_persistence() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(workspace.path_manager()).expect("persistence manager"), + ); + let manager = test_manager(persistence_manager); + let session = manager + .create_session( + "Persisted model update".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().into_owned()), + model_id: Some("primary".to_string()), + ..Default::default() + }, + ) + .await + .expect("session should create"); + + manager + .update_session_model_id(&session.session_id, "auto") + .await + .expect("model update should persist"); + manager.sessions.remove(&session.session_id); + + let restored = manager + .restore_session(workspace.path(), &session.session_id) + .await + .expect("session should restore from persistence"); + assert_eq!(restored.config.model_id.as_deref(), Some("auto")); + } + #[tokio::test] async fn session_storage_identity_rejects_same_id_in_another_workspace() { let workspace = TestWorkspace::new(); diff --git a/src/crates/assembly/core/src/product_runtime.rs b/src/crates/assembly/core/src/product_runtime.rs index f466d331da..e7966219ad 100644 --- a/src/crates/assembly/core/src/product_runtime.rs +++ b/src/crates/assembly/core/src/product_runtime.rs @@ -283,12 +283,6 @@ impl CoreAgentRuntimeCompatibility { .await } - pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> { - self.coordinator - .update_session_model(session_id, model_id) - .await - } - pub async fn branch_session_at_latest_turn( &self, workspace_path: &Path, diff --git a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs index d71ba6a23f..1ea27e33b6 100644 --- a/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/assembly/core/src/service/remote_connect/bot/command_router.rs @@ -418,9 +418,19 @@ async fn select_model( ); } }; + let runtime = match CoreServiceAgentRuntime::agent_runtime(coordinator.clone()) { + Ok(runtime) => runtime, + Err(error) => { + return result_from_menu( + state, + MenuView::plain(format!("{}{error}", s.switch_model_failed_prefix,)), + ); + } + }; match CoreServiceAgentRuntime::update_remote_session_model( coordinator.as_ref(), + &runtime, &session_id, model_id, ) diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index 9c6660c8c4..d435d76355 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -7,7 +7,7 @@ use bitfun_agent_runtime::sdk::{ AgentEventSource, AgentInteractionResponsePort, AgentRuntime, AgentRuntimeBuilder, - AgentSessionRestorePort, RuntimeError, + AgentSessionModelPort, AgentSessionModelUpdateRequest, AgentSessionRestorePort, RuntimeError, }; use bitfun_runtime_ports::{ AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, @@ -397,6 +397,7 @@ fn agent_input_attachment_from_image_context(context: ImageContextData) -> Agent fn core_agent_runtime_builder( submission: Arc, session_management: Arc, + session_model: Arc, session_restore: Arc, transcript_reader: Arc, thread_goal_management: Arc, @@ -408,6 +409,7 @@ fn core_agent_runtime_builder( AgentRuntimeBuilder::new() .with_submission_port(submission) .with_session_management_port(session_management) + .with_session_model_port(session_model) .with_session_restore_port(session_restore) .with_session_transcript_reader(transcript_reader) .with_thread_goal_management_port(thread_goal_management) @@ -710,6 +712,7 @@ impl CoreServiceAgentRuntime { pub(crate) async fn update_remote_session_model( coordinator: &ConversationCoordinator, + runtime: &AgentRuntime, session_id: &str, model_id: &str, ) -> Result { @@ -745,11 +748,13 @@ impl CoreServiceAgentRuntime { .map_err(|e| format!("Failed to restore session: {e}"))?; } - coordinator - .get_session_manager() - .update_session_model_id(session_id, &normalized_model_id) + runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: session_id.to_string(), + model_id: normalized_model_id.clone(), + }) .await - .map_err(|e| e.to_string())?; + .map_err(Self::runtime_error_message)?; if coordinator .get_session_manager() @@ -785,6 +790,7 @@ impl CoreServiceAgentRuntime { ) -> Result { let submission: Arc = coordinator.clone(); let session_management: Arc = coordinator.clone(); + let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); @@ -794,6 +800,7 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_model, session_restore, transcript_reader, thread_goal_management, @@ -811,6 +818,7 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); @@ -822,6 +830,7 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_model, session_restore, transcript_reader, thread_goal_management, @@ -841,6 +850,7 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); @@ -851,6 +861,7 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_model, session_restore, transcript_reader, thread_goal_management, @@ -869,6 +880,7 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); @@ -880,6 +892,7 @@ impl CoreServiceAgentRuntime { core_agent_runtime_builder( submission, session_management, + session_model, session_restore, transcript_reader, thread_goal_management, @@ -939,6 +952,7 @@ impl CoreServiceAgentRuntime { let submission: Arc = coordinator.clone(); let session_management = scheduled_session_management_port(coordinator.clone(), scheduler.clone()); + let session_model: Arc = coordinator.clone(); let session_restore: Arc = coordinator.clone(); let transcript_reader: Arc = coordinator.clone(); @@ -950,6 +964,7 @@ impl CoreServiceAgentRuntime { let builder = core_agent_runtime_builder( submission, session_management, + session_model, session_restore, transcript_reader, thread_goal_management, @@ -1439,6 +1454,7 @@ impl RemoteSessionRuntimeHost for CoreRemoteSessionRuntimeHost { ) -> Result { CoreServiceAgentRuntime::update_remote_session_model( self.coordinator.as_ref(), + &self.runtime, session_id, model_id, ) diff --git a/src/crates/contracts/runtime-ports/src/lib.rs b/src/crates/contracts/runtime-ports/src/lib.rs index dbd3580a90..1dcc1c668f 100644 --- a/src/crates/contracts/runtime-ports/src/lib.rs +++ b/src/crates/contracts/runtime-ports/src/lib.rs @@ -1017,6 +1017,13 @@ pub struct AgentSessionDeleteRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionModelUpdateRequest { + pub session_id: String, + pub model_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AgentSessionWorkspaceRequest { @@ -1690,6 +1697,12 @@ pub trait AgentSessionManagementPort: Send + Sync { ) -> PortResult>; } +#[async_trait::async_trait] +pub trait AgentSessionModelPort: Send + Sync { + async fn update_session_model(&self, request: AgentSessionModelUpdateRequest) + -> PortResult<()>; +} + #[async_trait::async_trait] pub trait AgentDialogTurnPort: Send + Sync { async fn submit_dialog_turn( @@ -2690,6 +2703,10 @@ mod tests { remote_connection_id: Some("conn-1".to_string()), remote_ssh_host: Some("host-1".to_string()), }; + let model_request = AgentSessionModelUpdateRequest { + session_id: "session_1".to_string(), + model_id: "provider/model".to_string(), + }; let workspace_request = AgentSessionWorkspaceRequest { session_id: "session_1".to_string(), }; @@ -2703,6 +2720,7 @@ mod tests { let list_json = serde_json::to_value(list_request).expect("serialize list request"); let summary_json = serde_json::to_value(summary).expect("serialize summary"); let delete_json = serde_json::to_value(delete_request).expect("serialize delete request"); + let model_json = serde_json::to_value(model_request).expect("serialize model request"); let workspace_json = serde_json::to_value(workspace_request).expect("serialize workspace request"); let binding_json = @@ -2718,6 +2736,8 @@ mod tests { assert_eq!(delete_json["sessionId"], "session_1"); assert_eq!(delete_json["remoteConnectionId"], "conn-1"); assert_eq!(delete_json["remoteSshHost"], "host-1"); + assert_eq!(model_json["sessionId"], "session_1"); + assert_eq!(model_json["modelId"], "provider/model"); assert_eq!(workspace_json["sessionId"], "session_1"); assert_eq!(binding_json["workspaceId"], "workspace_1"); assert_eq!(binding_json["workspacePath"], "/workspace/project"); diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index 53357543b3..ac72dc712e 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -13,14 +13,14 @@ use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, - AgentSessionManagementPort, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, - AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, - AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, - AgentTurnCancellationResult, DialogSubmitOutcome, PluginRuntimeBinding, PortError, - PortErrorKind, PortResult, RuntimeEventEnvelope, SessionTranscript, SessionTranscriptReader, - SessionTranscriptRequest, ThreadGoal, + AgentSessionManagementPort, AgentSessionModelPort, AgentSessionModelUpdateRequest, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, + AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, + AgentTurnCancellationRequest, AgentTurnCancellationResult, DialogSubmitOutcome, + PluginRuntimeBinding, PortError, PortErrorKind, PortResult, RuntimeEventEnvelope, + SessionTranscript, SessionTranscriptReader, SessionTranscriptRequest, ThreadGoal, }; use bitfun_runtime_services::RuntimeServices; @@ -184,6 +184,7 @@ pub trait RuntimeAgentRegistry: Send + Sync { pub struct AgentRuntime { submission: Arc, session_management: Option>, + session_model: Option>, session_restore: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, @@ -212,6 +213,13 @@ impl std::fmt::Debug for AgentRuntime { .as_ref() .map(|_| ""), ) + .field( + "session_model", + &self + .session_model + .as_ref() + .map(|_| ""), + ) .field( "session_restore", &self @@ -311,6 +319,7 @@ where pub struct AgentRuntimeBuilder { submission: Option>, session_management: Option>, + session_model: Option>, session_restore: Option>, session_transcript_reader: Option>, thread_goal_management: Option>, @@ -346,6 +355,11 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_model_port(mut self, port: Arc) -> Self { + self.session_model = Some(port); + self + } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { self.session_restore = Some(port); self @@ -437,6 +451,7 @@ impl AgentRuntimeBuilder { let Self { submission, session_management, + session_model, session_restore, session_transcript_reader, thread_goal_management, @@ -461,6 +476,7 @@ impl AgentRuntimeBuilder { Ok(AgentRuntime { submission: submission.ok_or(RuntimeBuildError::MissingSubmissionPort)?, session_management, + session_model, session_restore, session_transcript_reader, thread_goal_management, @@ -724,6 +740,22 @@ impl AgentRuntime { .map_err(RuntimeError::from) } + pub async fn update_session_model( + &self, + request: AgentSessionModelUpdateRequest, + ) -> Result<(), RuntimeError> { + let session_model = self.session_model.as_ref().ok_or_else(|| { + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session model port is not registered", + )) + })?; + session_model + .update_session_model(request) + .await + .map_err(RuntimeError::from) + } + pub async fn restore_session( &self, request: AgentSessionRestoreRequest, diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 2d8250389a..fc2f482cc5 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -57,16 +57,16 @@ pub use bitfun_runtime_ports::{ AgentBackgroundResultRequest, AgentDialogTurnPort, AgentDialogTurnRequest, AgentInputAttachment, AgentLifecycleDeliveryPort, AgentSessionCreateRequest, AgentSessionCreateResult, AgentSessionDeleteRequest, AgentSessionListRequest, - AgentSessionManagementPort, AgentSessionSummary, AgentSessionWorkspaceBinding, - AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, - AgentSubmissionResult, AgentSubmissionSource, AgentThreadGoalCreateRequest, - AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, AgentThreadGoalManagementPort, - AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, AgentTurnCancellationRequest, - AgentTurnCancellationResult, ClockPort, DialogSubmissionPolicy, DialogSubmitOutcome, - FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, PermissionPort, - PermissionRequest, PortError, PortResult, RemoteAssistantWorkspaceFacts, RemoteCapabilityPort, - RemoteConnectionPort, RemoteProjectionPort, RemoteRecentWorkspaceFacts, RemoteWorkspaceFacts, - RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, + AgentSessionManagementPort, AgentSessionModelPort, AgentSessionModelUpdateRequest, + AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, + AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, AgentSubmissionSource, + AgentThreadGoalCreateRequest, AgentThreadGoalDeliveryRequest, AgentThreadGoalGetRequest, + AgentThreadGoalManagementPort, AgentThreadGoalUpdateStatusRequest, AgentTurnCancellationPort, + AgentTurnCancellationRequest, AgentTurnCancellationResult, ClockPort, DialogSubmissionPolicy, + DialogSubmitOutcome, FileSystemPort, GitPort, McpCatalogPort, NetworkPort, PermissionDecision, + PermissionPort, PermissionRequest, PortError, PortResult, RemoteAssistantWorkspaceFacts, + RemoteCapabilityPort, RemoteConnectionPort, RemoteProjectionPort, RemoteRecentWorkspaceFacts, + RemoteWorkspaceFacts, RemoteWorkspaceFileRuntimeHost, RemoteWorkspaceKind, RemoteWorkspacePort, RemoteWorkspaceRuntimeHost, RemoteWorkspaceUpdate, RuntimeEventEnvelope, RuntimeEventSink, RuntimeEventType, RuntimeServiceCapability, RuntimeServicePort, SessionStorageKind, SessionStoragePathRequest, SessionStoragePathResolution, SessionStorePort, SessionTranscript, @@ -114,6 +114,11 @@ impl AgentRuntimeBuilder { self } + pub fn with_session_model_port(mut self, port: Arc) -> Self { + self.inner = self.inner.with_session_model_port(port); + self + } + pub fn with_session_restore_port(mut self, port: Arc) -> Self { self.inner = self.inner.with_session_restore_port(port); self @@ -262,6 +267,13 @@ impl AgentRuntime { self.inner.delete_session(request).await } + pub async fn update_session_model( + &self, + request: AgentSessionModelUpdateRequest, + ) -> Result<(), RuntimeError> { + self.inner.update_session_model(request).await + } + pub async fn restore_session( &self, request: AgentSessionRestoreRequest, diff --git a/src/crates/execution/agent-runtime/tests/session_model_sdk.rs b/src/crates/execution/agent-runtime/tests/session_model_sdk.rs new file mode 100644 index 0000000000..ccba67b853 --- /dev/null +++ b/src/crates/execution/agent-runtime/tests/session_model_sdk.rs @@ -0,0 +1,101 @@ +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use bitfun_agent_runtime::sdk::{ + AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionModelPort, AgentSessionModelUpdateRequest, AgentSubmissionPort, + AgentSubmissionRequest, AgentSubmissionResult, PortError, PortResult, RuntimeError, +}; +use bitfun_runtime_ports::PortErrorKind; + +#[derive(Default)] +struct FakeSubmissionPort; + +#[async_trait] +impl AgentSubmissionPort for FakeSubmissionPort { + async fn create_session( + &self, + request: AgentSessionCreateRequest, + ) -> PortResult { + Ok(AgentSessionCreateResult { + session_id: "session-1".to_string(), + session_name: request.session_name, + agent_type: request.agent_type, + }) + } + + async fn submit_message( + &self, + request: AgentSubmissionRequest, + ) -> PortResult { + Ok(AgentSubmissionResult { + turn_id: request.turn_id.unwrap_or_else(|| "turn-1".to_string()), + accepted: true, + }) + } + + async fn resolve_session_agent_type(&self, _session_id: &str) -> PortResult> { + Ok(Some("agentic".to_string())) + } +} + +#[derive(Default)] +struct RecordingSessionModelPort { + requests: Mutex>, +} + +#[async_trait] +impl AgentSessionModelPort for RecordingSessionModelPort { + async fn update_session_model( + &self, + request: AgentSessionModelUpdateRequest, + ) -> PortResult<()> { + self.requests.lock().unwrap().push(request); + Ok(()) + } +} + +#[tokio::test] +async fn session_model_update_forwards_the_exact_typed_request() { + let model_port = Arc::new(RecordingSessionModelPort::default()); + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSubmissionPort)) + .with_session_model_port(model_port.clone()) + .build() + .expect("runtime"); + let request = AgentSessionModelUpdateRequest { + session_id: "session-1".to_string(), + model_id: "provider/model".to_string(), + }; + + runtime + .update_session_model(request.clone()) + .await + .expect("model update"); + + assert_eq!(model_port.requests.lock().unwrap().as_slice(), &[request]); +} + +#[tokio::test] +async fn session_model_update_reports_a_missing_port_without_fallback() { + let runtime = AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(FakeSubmissionPort)) + .build() + .expect("runtime"); + + let error = runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: "session-1".to_string(), + model_id: "provider/model".to_string(), + }) + .await + .expect_err("a missing model port must not silently fall back"); + + assert_eq!( + error, + RuntimeError::Port(PortError::new( + PortErrorKind::NotAvailable, + "agent session model port is not registered", + )) + ); +} diff --git a/src/crates/execution/tool-contracts/src/framework.rs b/src/crates/execution/tool-contracts/src/framework.rs index 12191c0db9..594da9d979 100644 --- a/src/crates/execution/tool-contracts/src/framework.rs +++ b/src/crates/execution/tool-contracts/src/framework.rs @@ -2549,7 +2549,7 @@ mod tests { fn get_tool_spec_description_preserves_prompt_contract() { let description = build_get_tool_spec_description(); - assert!(description.contains("Read full schema")); + assert!(description.contains("Read the full schema")); assert!(description.contains("Do not call GetToolSpec again")); } diff --git a/src/crates/interfaces/acp/src/runtime/model.rs b/src/crates/interfaces/acp/src/runtime/model.rs index 990d2f16db..4776cb88a4 100644 --- a/src/crates/interfaces/acp/src/runtime/model.rs +++ b/src/crates/interfaces/acp/src/runtime/model.rs @@ -4,6 +4,7 @@ use agent_client_protocol::schema::{ SetSessionModelRequest, SetSessionModelResponse, }; use agent_client_protocol::{Error, Result}; +use bitfun_agent_runtime::sdk::AgentSessionModelUpdateRequest; use bitfun_core::agentic::agents::get_agent_registry; use bitfun_core::service::config::types::AIConfig; use bitfun_core::service::config::{GlobalConfig, GlobalConfigManager}; @@ -73,10 +74,13 @@ impl BitfunAcpRuntime { let normalized_model_id = normalize_model_selection(model_id).await?; - self.compatibility - .update_session_model(&bitfun_session_id, &normalized_model_id) + self.agent_runtime + .update_session_model(AgentSessionModelUpdateRequest { + session_id: bitfun_session_id, + model_id: normalized_model_id.clone(), + }) .await - .map_err(Self::internal_error)?; + .map_err(|error| Self::internal_error(error.into_message()))?; if let Some(mut state) = self.sessions.get_mut(session_id) { state.model_id = normalized_model_id; @@ -228,6 +232,17 @@ mod tests { use super::{current_model_id, normalize_session_model_id, AUTO_MODEL_ID}; use bitfun_core::service::config::types::{AIConfig, AIModelConfig}; + #[test] + fn acp_session_model_mutation_uses_the_runtime_sdk() { + let source = include_str!("model.rs"); + let runtime_update = ["agent_runtime", "\n .update_session_model"].concat(); + let compatibility_update = + ["compatibility", "\n .update_session_model"].concat(); + + assert!(source.contains(&runtime_update)); + assert!(!source.contains(&compatibility_update)); + } + #[test] fn normalize_session_model_defaults_to_auto() { assert_eq!(normalize_session_model_id(None), AUTO_MODEL_ID); diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 1f92d90a6b..0bf97046dc 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -2257,6 +2257,7 @@ fn remote_connect_tracker_keeps_subagent_items_out_of_parent_accumulators() { parent_dialog_turn_id: "parent-turn".to_string(), parent_tool_call_id: "task-1".to_string(), agent_type: None, + model_id: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "child-session".to_string(),