diff --git a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md index e8d520af4..4da0b7807 100644 --- a/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md +++ b/.agents/notes/implemented/architecture/2026-09-13-language-tooling-and-lsp-runtime-ownership.md @@ -78,6 +78,20 @@ Rename/Formatting/Code Action/Resolve/Execute Command 目前仍是 LSP-only,未声明能力时返回明确的 capability 错误。Provider 抛错不会 让路由提前结束,用于隔离第三方语言服务器故障。 +### Windows 语义颜色请求 + +Windows 的语义高亮复用 Core 已有 `semanticTokens` 操作与请求时捕获的图例, +前端只将归一化结果映射成 Monaco 的固定图例,不再解码服务器原始相对坐标。 +仅移除客户端能力检查并不能修复 #675:适配器必须映射命令,服务器的 +`semanticTokensRefresh` 也必须触发 Monaco 重新请求。 + +正确顺序是先冲刷编辑器待发送的文档变更,再请求颜色,最后检查模型版本、 +文件路径、文档打开状态与取消状态。否则请求可能基于服务器尚未更新的旧文本, +即便 Monaco 版本号在请求期间没变,也会把旧位置应用到新文本。 +适配器还会核对文件 attachment(文档与语言服务器会话的归属对象),归属被替换 +后丢弃旧响应。取消与 deadline 继续走现有请求路径;设置开关和大文件降级仍由 +Monaco 选项控制,缺少协商能力时保留基础语法高亮。 + ### 会话生命周期不变量 会话生命周期必须是单一判别状态;capability 协商单独表示为 `unknown` @@ -215,6 +229,12 @@ capability 为准。 ## 验证 +Windows 语义颜色使用 `lsp-core-adapter.test.ts` 和 +`semantic-token-provider.test.ts` 消费同一份 +`shared/fixtures/lsp/semantic-tokens-v1.json`,覆盖命令路由、图例转换、刷新通知、 +取消、归属替换、同步先后顺序及过期模型。Linux 可运行这些确定性前端测试; +真实 JDTLS 与 WebView2 上色、大文件降级仍需 Windows 验收。 + Windows 启动回归由 `workspace-git-bootstrap.test.ts` 验证恢复 Java 文档与后台 共用准备任务、子仓库未完成时不启动 Maven、失败后继续、关闭重开丢弃旧结果, 以及文件树仍能匹配主仓库的修改标记。相关用例与扫描合并、Git 历史恢复用例 diff --git a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md index 551b6f122..04ce26f65 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md +++ b/.agents/notes/implemented/architecture/2026-09-21-maven-settings-reach-the-language-server.md @@ -239,6 +239,26 @@ Maven、版本、路径和来源,例如「自动 → JDK 21.0.4 · 路径 · 「自动」本身的选择规则(是否按项目要求版本挑选、Windows 候选排序)属于 #815,不在此处 改变。 +### Maven 设置页的检测边界(#844) + +Windows Maven 设置页的空字段展示当前已保存配置下的生效值。例如选择了团队的 +`settings.xml`,本地仓库留空时应显示该文件声明的仓库,不能显示忽略团队设置后 +得到的 `~/.m2/repository`。Maven 安装使用启动上下文已经解析的路径;JDK 与独立 +Maven 目标共用项目默认值继承逻辑。显式字段不再显示未启用的自动候选,检测中 +也不继续显示上一份配置的路径。切换项目必须丢弃未保存草稿,即使两个项目的 +已保存字段刚好相同。 + +检测命令在后台工作线程执行。Maven 路径只做文件检查,不运行 Wrapper;JDK +仅探测 Java 候选,不连带运行 Maven、Node。每次版本探测设三秒截止时间,复用 +`lithe-git-host` 的原生进程执行器处理输出上限、进程树终止和有界清理,避免用户 +打开设置就因 Wrapper 下载或卡住的版本命令冻结界面。 + +设置文件中的仓库提示复用 Core 已使用的 `quick-xml`,解码实体和 CDATA(XML 中 +保留原始文字的区段),只读取 `settings` 的直接子元素,并读完整个文档后再接受 +结果。不能截取标签间的原始字符串,否则 `D:/R&D` 会变成错误目录,缺失闭合 +标签也可能被当作路径。这里只提供只读提示,不生成 Maven 的完整生效模型; +`${user.home}` 以外的属性保持原文,完整插值和最终构建配置仍由 Maven 负责。 + ## 验证 - Rust Core:`cargo test --manifest-path rust/lithe-core/Cargo.toml` @@ -254,6 +274,11 @@ Maven、版本、路径和来源,例如「自动 → JDK 21.0.4 · 路径 · - Windows 宿主:`cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml maven_resolution_without_probing` 覆盖 Wrapper 优先、主目录与启动器两种覆盖 写法、残缺 Wrapper 不被选中。 +- Maven 设置:Windows 宿主 `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml + maven::settings_xml` 覆盖实体、CDATA、命名空间、注释和不完整 XML;同一宿主的 + `toolchain_probe_` 测试覆盖已到期的探测和两个输出流。前端 + `maven-detected-value.test.tsx`、`maven-project-jdk.test.ts`、`maven.store.test.ts` + 验证显示与启动选择一致,`maven-settings-panel.test.tsx` 验证项目切换丢弃草稿。 - 共享契约:`./scripts/verify-shared-contracts.sh`。 - 测试稳定性:`./.agents/skills/write-stable-tests/scripts/verify-test-stability.sh`。 - 生效值显示:Windows 宿主 `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml diff --git a/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md new file mode 100644 index 000000000..e8a815fd7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md @@ -0,0 +1,132 @@ +# Agent 笔记:共享 ACP Agent 对话 + +状态:已实现 + +## 先说结论 + +Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 Agent。每个项目只有一个 Agent 进程,一个进程里可以有多个会话;会话历史由 Agent 自己保存;Lithe 负责显示,并单独保存收藏、自定义标题和可恢复的隐藏状态。第一阶段只支持用户自己的 API Key,不接任何官方账号登录。Agent 需要的 Node.js 由用户自己安装,Lithe 只负责检测;ACP 适配器由 Lithe 提供一键安装,安装时用的是用户本机的 npm。ACP(Agent Client Protocol,编辑器与 Agent 之间的对话协议)连接和进程管理写在同一个 Rust crate 里,Mac 与未来的 Windows 只各自实现界面。 + +## 问题 + +如果两端各写一套 ACP 连接、权限回复与进程清理,协议行为容易分叉。若在应用启动时就启动 Agent,不用这个功能的用户也要承担内存和进程开销。Agent 会请求执行工具,权限必须由用户确认。 + +实测还发现两个会直接影响用户的问题: + +- codex-acp 1.13.1 在"刚发出消息就取消"时会丢掉这次取消。它此时已经拿到本轮 ID,但 Codex 还没把这一轮标记为进行中,于是中断请求返回"没有进行中的一轮"。结果是这条消息的请求永远不返回,本轮在后台继续跑完并消耗额度,下一条消息还会被塞进这一轮。 +- 打开 codex-acp 的 `APP_SERVER_LOGS` 调试日志后,网关请求头(包括明文 API Key)会写进日志文件。 + +## 决策 + +- **共享实现**:`rust/lithe-agent-host` 使用官方 `agent-client-protocol` SDK。一个 `AgentHandle` 对应一个项目的 Agent 进程和 ACP 连接,负责初始化、网关登录、会话新建/列出/加载、消息、权限、取消和进程树清理。Mac 通过 Rust Core C ABI(`lithe_agent_open_json`、`lithe_agent_send_json`、`lithe_agent_close`)调用;Windows 以后直接依赖同一个 crate。命令和事件的 JSON 形状由 `shared/fixtures/agent/acp-events-v1.json` 固定。 +- **按需启动,跟着项目走**:`LitheAgentConversationModule` 是内置可选模块,默认禁用。每个项目有自己的模块运行时,所以会话天然属于项目。切换标签或窗口不会结束任何会话,后台项目的这一轮会继续跑完;只有关闭项目、关闭功能或退出应用时才停止 Agent。后台项目的会话在等待权限时,项目标签上会显示提醒点。 +- **只用 API Key 登录**:初始化时声明 `auth._meta.gateway = true`,然后只用 `gateway` 方式登录,把服务商地址和 `Authorization: Bearer ` 放进 `authenticate` 请求,经 stdio 传给 Agent。Key 不进命令行参数、环境变量或文件,Lithe 也不设置 `APP_SERVER_LOGS`。Agent 不提供 `gateway` 登录时直接报错,不会退而使用它的账号登录。第一阶段只支持 Responses 协议的服务商,也就是 codex-acp。 +- **历史以 Agent 为准**:会话列表来自 `session/list`,打开旧会话用 `session/load`,由 Agent 回放历史。Lithe 不自己保存聊天记录。Agent 进程重启后,旧会话必须先加载才能继续发消息。导出按 [ACP 会话加载契约](https://agentclientprotocol.com/protocol/v1/session-setup) 等待完整回放后返回的 `session/load` 响应;Swift 接收到 `sessionLoaded` 时先刷新缓冲文本,再生成导出快照,不靠固定延迟猜测回放完成。 +- **历史页与本地管理**:参考 [CC GUI 历史页](https://github.com/zhukunpenglinyutong/jetbrains-cc-gui/tree/main/webview/src/components/history) 使用面板内返回、搜索、统计和分隔列表。搜索只查询标题和 ID,数量只统计已加载的用户/Agent 消息,不扫描 CLI 文件,也不伪造上游未提供的消息总数。`AgentHistoryFeatureModel` 通过 `AgentHistoryPersisting` 保存收藏、标题覆盖和隐藏标记;macOS adapter 使用既有偏好设置,键为 `lithe.agent-history.v1.`,按标准化工作区与配置 Agent ID 隔离。变更前重新读取,避免另一窗口的旧快照覆盖最近注释;解码或保存失败明确显示,不重置损坏状态。移除仅隐藏本地列表,提供“已移除的会话”筛选与恢复,不宣称删除 Agent 原始会话。单条和批量移除都先弹出确认框,取消不写元数据;确认操作使用弹窗出现时的会话 ID 快照,避免把后续选择变化带入删除。进入历史页保留对话视图和输入草稿,不停止正在进行的轮次。 +- **导出与资源所有权**:导出为用户选择位置的 Markdown,包含用户、Agent 和工具消息,工具输入/输出/内容一并保留。未打开会话复用现有、有请求期限的 ACP 加载路径,逐个加载,不改变选中标签;加载失败不写部分导出;只有会话成功创建或完整加载后才标记历史快照可复用,消息非空不能证明完整性。失败或断连留下的部分回放必须重载,已完整加载的快照在断连后仍可导出。取消与模块关闭释放等待 continuation(异步结果回调),原加载请求可自然结束。正在回复或加载中的会话不导出。导出 Task 由历史功能模型拥有并在模块停止时取消、等待结束;平台 adapter 拥有保存对话框与原子文件写入,最大 32 MiB。偏好设置与用户导出都不是构建缓存,不新增下载、不跨 worktree 复制;见 `scripts/worktree-resources.json`。它们不写发行 bundle、不影响代码签名和 Sparkle delta。 +- **上下文用量以上游为准**:输入框顶部参考 CC GUI 显示圆环与整数百分比,悬停显示一位小数百分比及已用/总容量 token。提示复用工作台已有的悬停浮层与面板局部 scope(浮层可绘制的范围),圆环和百分比共用完整命中区域,鼠标进入立即显示在指示器上方,移开或面板退出时关闭;不依赖系统原生 `.help` 的延迟提示,避免宿主视图内看不到详情。使用现有 ACP `usage_update.used/size`,共享 host 已按 SDK 原样转发,不另接 Codex 私有事件、不扫描历史文件、不根据文字长度或模型名推算。计费用量是累计消耗,不是当前上下文容量,不能混用。用量按会话保存,每条有效更新替换之前值,压缩后允许下降;没有上报、容量为零或无效数据在会话状态中仍保持未知;展示层按 CC GUI 使用 0% 占位,悬停仅提示“上下文: 0.0%”,真实零使用量也使用相同简短提示,不编造已用 token 或总容量。断连、确认模型改变、重新加载会话清除旧数据,权限或思考选项改变不清除。超过容量时保留上游数值与百分比,仅圆环限制为一整圈。数据只是内存会话状态,不写入 bundle 或新增缓存资源。 +- **停止必须等上游确认**:只发送一次 `session/cancel`,撤销本轮权限请求,界面进入“正在停止”。收到原 prompt 的结束响应后才能发送下一轮。旧方案只屏蔽迟到的 prompt 响应,却不能阻止上游把新消息并入旧轮次,也不能识别没有轮次编号的迟到通知。因此改为十秒确认期限:超过期限则明确报错、停止该 Agent 的进程树,保留界面记录,用户重连后通过 `session/load` 恢复。正常取消不重启进程。这不是伪装成正常结束,用户会看到恢复原因;同一 Agent 进程里的其他会话也会断开,不能静默自动重试消息。 +- **会话配置由上游提供**:面板连接完成后准备空会话,让用户发第一条消息前就能选择模型、权限模式、思考强度。选项、分组和当前值均来自 `session/new`、`session/load`、配置更新通知及 `session/set_config_option` 的响应;不硬编码模型列表。每次启动 Agent 连接前,通过既有本机配置端口刷新已绑定导入服务商的默认模型,避免旧导入值(例如已移除的模型名)再次成为所有新会话的默认值;只刷新模型,不改提交信息的服务商选择、手动服务商、端点或凭据。模型读取独立于完整 API 配置:Codex 没有自定义服务商或 API Key 时仍能读取顶层 `model`,不能把其他 TOML 表里的同名字段当成默认模型;凭据校验失败时也更新面板的模型回退显示。读取模型不会放开第一阶段的 API Key 登录要求。仅刷新本机配置仍不够:codex-acp 1.13.1 会把不在实际模型目录中的配置值临时补进选项。共享 host 保留上游同时返回的旧式模型目录,并协商其版本化推荐值扩展(`jetbrains.air.recommendedValue`);只有当前值确实不在目录中、推荐值同时存在于目录和选项中时,新会话才通过标准 ACP 配置请求切到该推荐值。等待上游确认后再发布会话,两次请求共用创建会话的有界超时。配置中有效的模型、历史会话和用户文件不被自动改写;扩展或目录缺失时保留标准 ACP 行为,不猜默认模型、不按版本名或描述筛选。ACP 模型菜单使用该 Agent 已有的品牌 SVG,不使用通用 CPU 图标;显示标签仍由上游确认的当前值决定。请求完成前禁止重复配置和发送,失败保留之前确认的值并显示错误。适配器没暴露的配置不画假控件,也不替用户改变网关地址或全局 CLI 配置。 +- **工具证据与文档保护**:工具详情合并上游的部分更新,展示类型、输入、输出、文件位置和修改前后文本;权限卡复用已收到的工具证据,区分允许和拒绝。显示文本每段限制 32 Ki 字符,内容和位置各限制 100 条,避免大工具输出堵住界面;截断处标记 `[...]`。点击项目内文件交给现有编辑器导航,不在视图中直接读写文件。磁盘刷新、脏缓冲区保护和 Git diff 沿用文档/Git 模块。Agent 从项目目录启动,不需要先附加当前文件才能读写磁盘;未保存的编辑器快照是后续独立能力。 +- **未发送的空会话不能当成历史恢复**:为了提前显示配置,界面会先创建空会话,但 Codex 在首条消息前还没有把会话内容写入磁盘(rollout),旧进程关闭后用原 ID 加载会报 `no rollout found`。连接模型只跟踪本连接新建、未成功提交消息且未出现在上游历史中的会话;断开时移除这些会话的本地标签和临时记录,重连后通过原有准备流程新建。已有消息、上游列出或成功加载的会话继续恢复,不根据“当前消息列表为空”推断历史不存在,也不按错误字符串给真实历史自动新建替身。这个操作不删除用户的上游历史文件;代价是未发送会话的临时配置选择需要在新连接重新确认。 +- **历史加载失败不清空记录**:回放时保留原会话快照,成功后使用上游回放,失败或连接退出时恢复之前记录并丢弃不完整的回放片段。旧连接事件任务被取消后不再消费缓冲事件。 +- **并发权限与中断状态**:同一会话的多个权限申请按到达顺序排队,答复一个后展示下一个,取消时一起撤销。轮次或连接结束时,没有收到最终状态的工具标记为中断,不再无限显示运行中。 +- **本机与自定义供应商**:面板设置保留本机 CLI 读取入口,并支持添加、编辑、启用和确认删除自定义供应商。自定义编辑器接受 Codex TOML/auth JSON 或 Claude settings JSON,但只提取 API 地址、模型和 API 密钥;其余 CLI 选项仍由本机文件决定,编辑器明确提示这一范围。TOML/JSON 元数据复用共享 Core 的现有 AI 解析器,不在 Swift 重新实现 TOML;原文仅存在编辑草稿,密钥由 Mac 适配器提取并交给现有安全存储。解析响应和错误不含密钥、原文或解析器诊断。配置编辑器沿用源码/SQL 编辑器的纯文本策略,关闭智能引号、破折号替换与自动纠错,避免合法配置在输入时被系统改坏。保存错误固定在滚动区外的按钮上方,不能把错误藏到长配置底部;已有弯引号导致 TOML 失败时明确提示改用直引号。供应商元数据复用既有 AI 服务商列表,添加、编辑、启用不改变提交信息的服务商选择;删除会同时移除列表里的供应商及其密钥,界面先说明影响并要求确认。 +- **切换连接**:当前项目的 Agent 正在响应、加载会话、创建会话、等待权限或确认配置时,拒绝切换;空闲时先等待旧连接关闭,再绑定供应商并重连。编辑或删除当前项目正在使用的自定义供应商也遵循这个顺序。保存操作在等待旧连接关闭前记录供应商快照,写入密钥前同步核对共享注册表;若另一窗口已删除或修改该记录,旧保存操作必须失败,不能重新创建供应商或覆盖新配置。检查、密钥写入和元数据发布之间不得异步等待。密钥写入失败不会发布新配置,删除密钥失败会保留供应商供重试,重连使用原配置。现有本机凭据文件损坏或版本不支持时,写入和删除必须报错并保留原始文件,不能按空文件覆盖。其他项目已经建立的连接继续完成自己的工作,在下一次连接时读取最新供应商;不得为了全局设置变化中断后台项目的轮次。切换保留面板记录,恢复已保存会话仍通过上游历史加载。 +- **Agent 管理(Agent 面板内的设置)**: + - **支持的 Agent 目录**:写在 `lithe-agent-host` 的 `catalog.rs`,Agent ID、npm 包名和固定版本都与 ACP 官方注册表一致。首批是 Codex(已用真实服务商验证)和 Claude(标为"未验证")。只有重新验证过的版本才会提升。 + - **环境检测**:通过登录 shell 读取 `PATH`,所以 nvm、fnm 装的 Node 也能找到。检测 Node 和 npm 的版本,每个 Agent 各有最低 Node 版本(Codex 20、Claude 22),版本不够时只提示。 + - **一键安装**:用用户的 npm 把固定版本的适配器装到 `Application Support/Lithe/agents/`。先装到临时目录,确认可执行文件存在后再替换旧目录,所以失败或取消不会破坏已经能用的旧版本。 + - **复用用户本机的 Agent CLI**:两个适配器都自带一份 Agent 的原生程序,都是可选依赖,单个平台约 200–370 MB,而用户本机本来就有。所以安装时加上 `--omit=optional`(Codex 装完约 18 MB)。启动时在登录 shell 的 PATH 里找到用户的 CLI 交给适配器:Codex 用 `CODEX_PATH`(最低 0.156.0),Claude Code 用 `CLAUDE_CODE_EXECUTABLE`(最低 2.1.280,对应 SDK 的 `claudeCodeVersion`)。CLI 找不到时可由 npm 安装;版本过旧时先确认当前 PATH 命令的安装来源,再通过原安装器更新。来源展示在预检项中,npm/Homebrew/原生安装可以按验证结果提供升级按钮,未知来源只给手动指引。所有安装与更新只在用户明确点击时运行,Node.js 和 npm 仍由用户自己安装。CLI 过旧不阻止安装适配器,只有 Node.js 或 npm 不可用才阻止。 + - **CLI 更新保留安装来源**:以 PATH 中实际命令及其真实文件为准,不能仅看到用户装了 npm 就把所有 CLI 交给 npm。Homebrew 通过自己报告的 Cellar/Caskroom 位置和已安装记录确认归属,保留 cask/formula 及 `claude-code@latest` 等渠道;npm 必须确认当前 global root、包名、bin 声明和链接都指向同一 CLI,另一套 Node 环境不能代更新;Claude 标准原生 launcher 使用上游 `claude update`。未知来源、损坏链接、缺少原安装器或安装记录时拒绝自动覆盖,明确提供手动指引。更新后重新读取登录 shell 的 PATH 并验证最低版本;更新命令退出成功但实际 CLI 仍过旧也应失败。安装器可能在首次下载失败后重试成功,却保留非零退出状态,因此正常结束的命令无论退出状态如何,都要检查实际版本。只有新安装或数字版本严格提升且达到最低要求时,才能把非零退出降为“已成功、带警告”,返回有界日志并在界面折叠展示;原本可用但版本没变、降级、仍过旧或找不到命令时继续报错。不能根据日志中的“successfully upgraded”字样猜测成功,也不能把取消、超时或启动失败改判成功。读取来源只使用有界的本地查询,不更新包管理器索引、不改变用户配置。Homebrew 和原生下载归原安装器拥有,仅显示“正在更新”与耗时,不伪造字节进度;它们的全局安装和缓存不注册成可复制的工作树构建资源,排除清单与测试同步维护。 + - **下载进度以 npm 的真实传输为准**:安装与 CLI 升级通过现有 Core 事件回调报告已接收软件包字节数、最近采样速度、耗时和等待时间。npm 没有提供整次安装的总量,且会继续发现依赖,所以不显示总体百分比。内嵌的 Node 观察模块只统计 HTTP 响应进入流缓冲区的字节,不添加消费数据的监听器,也不重写下载、代理、重试、校验或缓存行为。模块通过内存中的 data URL 加载,启动后先恢复用户原有 `NODE_OPTIONS`,防止 npm 子脚本继承观察器;不生成辅助文件或新的可复用缓存。正确做法是显示“已下载 25 MB、75 KB/秒、已用时 300 秒”;不要把 npm 静默时的日志时间或整个共享缓存大小当成下载进度。Core 事件只携带数字和阶段,界面按操作标识丢弃迟到事件,完成、失败或取消后清除进度。 + - **Rust Core 命令**:`agent.status`、`agent.install`、`agent.uninstall`、`agent.installCli`,复用现有信封的取消和超时。 + - **Key 和模型的传法**:所有适配器都通过 ACP `gateway` 登录,Key 经 stdio 传给 Agent,请求头按协议选择:Responses 协议用 `Authorization: Bearer`,Anthropic 协议用 `x-api-key`。模型按适配器分别传:Codex 用 `CODEX_CONFIG`,Claude 用 `ANTHROPIC_MODEL`。服务商配置里的"模型"必须传给 Agent:实测某个网关禁用了 Codex 的默认模型,不传模型时 Agent 只会回复一条网关报错。 + - **设置放在面板里,只有 Agent 管理一页**:Agent 的开关、预检清单(Node、npm、CLI、适配器、本机配置)、适配器和 CLI 的一键安装都在 Agent 面板右上角的设置视图里,不进全局设置窗口。布局仿照 Codeg 和 CC GUI:左侧图标栏,右侧标题加分段切换各个 Agent。 + - **本机配置保留 CLI 所有权**:每个 Agent 通过本机配置行读取用户自己 CLI 的地址、模型和密钥(Codex 读 `~/.codex/config.toml` 和 `auth.json`,Claude 读 `~/.claude/settings.json` 和 `~/.claude.json`),生成的服务商配置绑定到该 Agent。本机模式的密钥不复制进 Lithe,启动时从用户文件现读;要改本机地址或密钥时编辑自己的文件再刷新。需要独立配置时使用上面的自定义供应商编辑器,不改写 CLI 文件,也不改变提交信息使用的服务商选择。 + - **关闭时仍能找到入口**:工作台始终保留 Agent 的静态入口声明,首次安装没有模块配置、手动关闭后也能打开面板。模块仍默认禁用,入口读取不调用 factory,不创建连接;禁用模块的通用贡献目录和其他模块的入口规则保持原样。未配置的面板立即显示原因和“打开 Agent 设置”操作,用户在面板内开启开关,无需修改本机配置文件。开启后复用运行时入口,按 ID 避免重复;关闭后保留面板和入口。不要把默认开关改为启用来修复发现入口的问题,否则会改变所有用户的后台启动行为。 + - **提示文案本地化**:Rust 返回的 `issues` 只决定能否安装;界面显示的原因由 Swift 按结构化状态(Node 版本、npm、CLI 版本)重新生成,这样中英文都能显示。 +- **面板始终显示完整布局**:功能关闭或没有配置 Agent 时,面板照样显示会话标题、消息区和输入框,用户可以输入;发送时先校验(功能是否开启、是否有已配置的 Agent、模块是否启动完成),不通过就在输入框上方给出提示并提供进入设置的按钮,不会启动任何进程。 +- **文件拖入对话框**:参考 CC GUI 普通文件的路径引用交互,原生输入框接受 Finder 和项目树的文件 URL,显示可移除标签,并支持文件选择器。一次最多 32 个引用,按拖入顺序去重;切换会话清除旧文件草稿,发送失败保留文字和引用。功能模型把引用与文字一起排队,Rust Host 用上游 ACP SDK 的 `resource_link` 发送,Agent 自己读取文件并遵守现有权限。文件 URL 是这次消息的原生上下文,不作为跨平台项目标识持久化。正确做法是发送“说明这两个文件”加两个引用;不要在 View 里读文件、复制进缓存或拼装隐式文件内容提示词。图片目前同样作为文件引用,没有缩略图或多模态字节上传;这避免了与本次需求无关的内存、大小和格式策略,后续需要图片输入时再按上游能力协商。 +- **对话面板**:停靠在编辑器右侧,与 Maven 共用右侧槽位。输入框下方可以切换 Agent,列出的是已经设置了服务商的 Agent;打开的会话以标签形式排列。每个 Agent 第一次被选中时才建立连接;项目里所有 Agent 的权限提醒会合并显示到项目标签上。 +- **对话区的视觉与交互**:按 CC GUI 参考设计使用单行会话标题、居中的品牌标志、上下文栏和底部工具栏。只有多会话或用户主动展开时才显示标签条,避免空会话标题重复。空态不放进滚动列表,否则无法在剩余高度内居中。输入区复用 `LitheSplitPaneView`,尺寸约束跟随可用高度,拖动只更新局部容器。搜索只筛选当前已加载消息,不请求 Agent 或改动历史。品牌 SVG 来自参考项目锁定的 `@lobehub/icons` 5.8.0,以静态源资源随 SwiftPM bundle 打包并携带 MIT 许可,不新增运行时下载或可复用缓存。配置选择器按参考项目的底栏设计:权限与模型入口位于输入区底部,模型弹出面板提供本地名称/ID/分组搜索、品牌图标、蓝色选中行和绿色勾选,不显示模型说明;权限弹出面板保留上游说明并本地化已有文案。思考强度、速度及其他上游配置放到模型面板底部的子菜单,未提供时不显示。选项数量和权限语义仍以 ACP 为准,不照截图补选项。选择后收起面板,原确认/失败状态机继续拥有当前值,等待确认时禁止再次配置。弹出与搜索状态只在视图内保存,会话切换重建选择器以清除旧会话的弹出状态;不重建输入框或清空草稿。使用 macOS 原生 popover 管理定位和关闭,设置子选项在同一个 popover 内展开侧栏,避免嵌套 popover 关闭父面板;不另写鼠标全局监听或位置缓存。上下文百分比使用 Agent 上报的用量,缺失时展示 0% 占位与“上下文: 0.0%”简短提示;占位不作为已上报用量写回会话状态。 +- **进程与诊断**:启动时把 Agent 可执行文件所在目录放到 `PATH` 最前面。npm 把 `codex-acp` 和 `node` 装在同一个目录,而 Mac 图形应用拿不到登录 shell 的 `PATH`。关闭时先向整棵进程树发 SIGTERM,并记录树里的每个进程 ID,稍等后再对仍存活的进程发 SIGKILL,因为 codex-acp 的 app-server 会比外层进程晚几秒退出。Agent 意外退出时,报错里附上 stderr 最后 20 行,其中的 Key 会被替换掉。 + +正确做法:新平台的界面通过平台适配器把 fixture 里的命令交给 `lithe-agent-host`,并把工具权限选择交给用户。 + +不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI。 + +## 考虑过的备选方案 + +### 两端各用平台语言实现 ACP + +界面开发起步更快,但连接、取消、权限和进程树清理都要写两遍,长期维护成本高。社区的 Swift SDK 也会带来第二套协议栈。 + +### 直接修改 Agent 历史文件来支持收藏、重命名和删除 + +这样看似能复刻参考项目的全部动作,但会让 Lithe 再实现各 CLI 的私有目录、文件格式、并发写入和索引,绕过 ACP 的所有权边界。当前采用 Lithe 自己的轻量注释以及可恢复隐藏,代价是 CLI 本身的名称和记录不受影响;未来 ACP 提供对应正式能力时再评估迁移。 + +### 整体复用 Codeg + +Codeg 是一个完整的 ACP 产品,可以参考,但它的工作台、状态和发布方式不适合嵌进 Lithe。直接用官方 SDK 可以复用协议实现,同时保留 Lithe 自己的模块生命周期。 + +### 项目失活时结束会话 + +这是上一版的做法,但点一下另一个窗口也会结束正在进行的对话。它不符合"会话属于项目"的使用习惯,已经撤回。 + +### Lithe 自带 Node.js,或用 `npx` 按需拉取适配器 + +Zed 就是这么做的。但会装 Agent 的用户本机通常已经有 Node。自带 Node 会让安装包变大,也多出一份需要维护更新的运行时。`npx` 会在第一次打开面板时悄悄联网下载,用户不知道装了什么。因此只检测 Node,不自带;适配器由用户点击后才安装,而且固定版本。 + +### 用户自己用 `npm -g` 装适配器 + +多数用户不知道"适配器"和 Agent 的命令行工具是两个东西,比如装了 `claude` 不等于装了 `claude-agent-acp`。全局安装还可能碰到权限问题,版本也不受控制。 + +### 取消超时后结束会话,或定时重发取消 + +定时重发取消针对单个适配器缺陷,不能成为通用协议策略,因此不采用。单纯超时结束并清空会话会丢上下文,也不采用。当前选择保留记录、显式报错并重连加载的有界恢复:代价是重启连接,但能保证下一轮不与未结束的旧轮混在一起。 + +### 环境变量传 Key、或把 `CODEX_HOME` 指到 Lithe 目录 + +`OPENAI_API_KEY` 这类环境变量只适用于官方地址,也更容易泄露。隔离 `CODEX_HOME` 会让 Agent 读不到用户自己的 MCP 服务器、Skills 和全局指令;而一旦设置了网关,请求本来就不会用到用户的账号登录,所以没有必要隔离。 + +### 用 npm 日志或另写下载器提供进度 + +删除一个硬编码的旧模型名无法适应下一次模型变化,按描述为空过滤会误伤合法选项,固定新模型名会绕过上游推荐。上游 [codex-acp 的 AIR 扩展](https://github.com/agentclientprotocol/codex-acp/blob/main/src/AirExtension.ts) 已能提供推荐值,旧式模型目录仍能区分临时补入的配置值,因此复用这些信息并等待 ACP 确认;不修改第三方适配器,也不再另起一个 CLI 进程查询模型。 + +npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输出,无法解释长时间下载;轮询用户共享缓存也会把其他 npm 进程的写入误算进来。另写下载器会重复 npm 的代理、重试和缓存边界。因此采用一个只观察实际流字节的 Node 模块,保留 npm 完整安装行为;代价是必须用本地 HTTP 测试保护 Node 观察点和流的背压(消费者来不及处理时暂停接收)语义。 + +### 不识别来源,失败后给 npm 加 `--force` + +这会覆盖 Homebrew 等安装器管理的命令链接,留下两个安装器争用同一文件,也可能让更新的是一份 CLI、PATH 运行的是另一份。统一迁移到 npm 还会改变用户的发布渠道。当前采用文件身份与原安装器记录确认后再更新:查询开销稍大,但能够保留安装方式;未识别的来源宁可显示手动步骤,也不猜测和覆盖。识别规则采用上游公开目录/命令,目录或包管理器输出变化时应补充契约与本地 fixture 测试。 + +## 后果 + +两端共享同一套协议和清理逻辑。功能关闭或没打开面板时,不会有 Agent 进程。一个项目只起一个进程,会话再多也一样。 + +代价: + +- Rust C ABI 和 fixture 成为兼容面,两端界面仍要分别维护。 +- 用户需要自行安装 Node.js,适配器可以在面板内安装。 +- codex-acp 丢失取消时,最多等待十秒后需要用户重连;同一进程的其他会话也会断开。上游未持久化的最后片段可能无法完整回放,界面保留旧记录用于诊断,不能保证 Agent 保存了未完成轮次。 + +## 验证 + +- `cargo test -p lithe-agent-host --manifest-path rust/Cargo.toml` +- `cargo test -p lithe-core agent`(`agent.*` 命令与 `shared/fixtures/agent/agent-management-v1.json`) +- 真实 Agent 端到端测试默认忽略,需要设置 `LITHE_ACP_E2E_*` 环境变量后运行:`cargo test -p lithe-agent-host --test real_agent -- --ignored`。设置 `LITHE_ACP_E2E_DATA_DIR` 时,会先用 npm 安装适配器,再从 Lithe 数据目录启动。 +- `shared/fixtures/agent/acp-events-v1.json` 同时由 Rust 序列化测试和 Swift 功能模型测试读取。 +- `macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` 使用真实模块运行时覆盖默认关闭、启用和再次关闭,验证静态入口及 action/renderer 绑定可用、不重复、不提前调用 factory,其他关闭模块不会跟着暴露。 +- 配置选项与确认、部分工具更新、停止期间拒绝新消息、虚拟时钟驱动的取消超时、加载失败恢复记录均有回归测试。空会话测试直接驱动连接事件,覆盖供应商重连、意外退出、多个临时标签清理、首条消息和文件排队、发送失败以及空历史和已收到消息的保护;测试连接在成功和失败路径都关闭。真实 Agent 集成测试在自动清理的临时项目执行“读取、修改、运行 Node 测试、继续追问”,另验证配置切换、取消恢复及进程重启后历史加载;不操作用户项目代码。 +- 文件引用回归覆盖中文/空格 URL、重复拖入与移除后重加、数量限制与无效批次、首条消息与历史加载排队、会话切换后的发送目标、发送失败可重试,以及 SDK 真实 `session/prompt` 中的 `resource_link` 和兼容文字消息;测试不读取或发送用户项目文件。 +- CLI 来源测试覆盖 Homebrew 与 npm 共存、formula/cask/渠道、npm bin 身份、另一套 Node 环境、Claude 原生、未知与坏链接、查询取消/超时、非零退出但实际升级成功、可用旧版本未变化、降级、数字等价版本和更新后 PATH 仍过旧;不执行用户级安装或外部网络下载。 +- `node --test scripts/test-reuse-worktree-resources.mjs`(拒绝复制用户级 CLI 安装与缓存) +- `node --test rust/lithe-agent-host/tests/npm-progress.test.mjs`(本地 HTTP 响应不被观察器消费,归档字节计数准确,元数据与重定向不计入) +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentHistoryTests`(元数据隔离、筛选、刷新 token、导出回放/失败/取消及窄宽主题布局;可选 `LITHE_AGENT_HISTORY_SCREENSHOTS` 使用合成数据输出截图) +- `./scripts/verify-runtime-bundle-immutability.sh` +- `./scripts/verify-module-boundaries.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- 在 Mac 上实测:连续对话、权限选择、刚发出就取消、切换项目标签后旧会话继续运行、打开历史会话、关闭项目后进程全部退出。 + +## 适用范围 + +`rust/lithe-agent-host/`、`rust/lithe-core/src/agent/`、`rust/lithe-core/src/runtime/ffi.rs`、`macos/Sources/Lithe/Views/Agent/`、`macos/Sources/LitheAgentConversationModule/`、`macos/Sources/Lithe/Platform/MacOS/Agent/`、`shared/contracts/rust-core-api.md`、`shared/contracts/application-boundary.md`。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md new file mode 100644 index 000000000..d35c1f5c3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-27-windows-git-path-identity.md @@ -0,0 +1,50 @@ +# Agent 笔记:Windows Git 路径往返必须保持文件身份 + +状态:已实现 + +## 先说结论 + +Windows 的原生网络路径(UNC)和扩展长度路径(verbatim,带 `\\?\` 前缀)必须先规范化,再判断是否为绝对路径。Git 暂不支持路径分量末尾为 ASCII 点或空格,保留的 DOS 设备文件名、扩展路径内的 `.`/`..` 分量,以及盘符/UNC 之外的设备命名空间;遇到这些输入必须明确报错,不能通过去前缀或清理空白访问另一个文件。 + +## 问题 + +Issue #627 中,前端先判断绝对路径再替换反斜杠,把原生 UNC 文件拼进了当前仓库。Core 同时无条件去掉规范路径的扩展前缀,但该前缀会改变 Windows 的路径解析规则,去掉后不一定还是原来的目录。 + +## 决策 + +- 前端文件解析先经过 `normalizeGitOperationPath`,再决定是否拼接仓库根;共享文件路径工具继续服务其他模块,Git 的拒绝策略单独放在 Git 边界。 +- 供 UI 比较和渲染使用的 `normalizeRepositoryPath` 对不支持的路径保留原值,不抛错也不改写身份。执行入口使用带校验的函数,让仓库发现等异步流程把错误写入已有界面状态。 +- Core 在文件系统查找之前、规范化之后及读取 Git 返回路径时校验不支持的名字,返回 `invalid_request`。已删除工作树仍可使用原路径展示,但不能让这个回退吞掉不支持路径的错误。 +- 去掉 Git 输出的行结束符时不使用会吞掉文件名空格的 `trim()`。正常盘符/UNC、中文、内嵌空格和长路径继续通过原生 Git 处理;不凭长度推断它们不可用。 +- `remote://`、`wsl://` 保留协议和远端 POSIX 文件名语义,不对远端目录套用 Windows 尾部点/空格限制。 + +正确例子:`\\?\C:\work\repo\src\main.ts` 解析为仓库 `C:/work/repo` 与相对文件 `src/main.ts`。 +不要把 `\\?\C:\work\repo.` 改写成 `C:/work/repo`,也不要在校验失败后改用活动仓库继续操作。 + +## 考虑过的备选方案 + +全链路保留扩展前缀可以支持更多特殊名字,但当前前端、Git 工作目录和原生监听器都消费普通路径,局部保留不能保证往返身份一致。静默删除尾部点、空格或前缀会访问错误对象,因此采用显式拒绝,待所有消费者能够保留语义时再扩大支持范围。 + +## 后果 + +普通 UNC 文件不会再被错误拼接,危险的路径转换会变为可见错误。代价是含尾部点/空格的 Windows 仓库当前不能使用 Git 功能;这项限制必须由两端校验和共享样例一起维护,不能只放开其中一端。 + +## 验证 + +- `shared/fixtures/git/windows-paths.json` 由前端与 Rust 测试共同读取。 +- `git_path_roundtrip` 原生集成测试使用真实 Git 仓库,把发现结果交给实际前端 TypeScript 规范化函数后回传 Core,核对提交、分支、stash 和链接工作树的监听目录。它需要 Git 和 Node.js 22.6+,子进程通过现有 Git Host 设置本地期限并清理进程树。 +- 长路径用例先在短路径建立真实仓库,再移入超长目录。Windows CI 已观察到文件系统可访问但创建 Git 进程的工作目录被 `ERROR_DIRECTORY` 拒绝;用例仅接受成功读到原提交,或带原生错误 267 的 `process_start_failed`,不能返回空仓库或访问其他目录。 +- Windows 专属测试真实创建末尾点/空格目录及普通同名目录,确认请求明确拒绝,不误用普通目录。 +- 原生 watcher 测试用有界事件接收验证外部提交/切换所写的共享引用与独立 HEAD 元数据会通知对应工作树;没有用 sleep 等待防抖。 +- `./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope SharedRust` +- `./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope WindowsRust` +- `./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope Frontend` + +## 适用范围 + +- `rust/lithe-core/src/git/mod.rs` +- `windows/tauri/src/features/git/api/git-repository-path.ts` +- `windows/tauri/src/features/git/api/git-repo-api.ts` +- `rust/lithe-core/tests/git_path_roundtrip.rs` +- `windows/tauri/crates/project/src/git_watcher.rs` +- `shared/contracts/rust-core-api.md` diff --git a/.agents/notes/implemented/feature/2026-08-17-windows-project-tab-bar.md b/.agents/notes/implemented/feature/2026-08-17-windows-project-tab-bar.md index d228220d6..37811184c 100644 --- a/.agents/notes/implemented/feature/2026-08-17-windows-project-tab-bar.md +++ b/.agents/notes/implemented/feature/2026-08-17-windows-project-tab-bar.md @@ -39,8 +39,27 @@ Tab Bar 使用固定高度和水平溢出容器,不改变标题栏尺寸,也 这样保留了文件系统 store 已有的切换保护,并避免用户在并发操作期间看到 过期项目状态。 +### 同一本地项目只属于一个窗口 + +Tauri 宿主记录目录实际对应的文件系统对象与所属窗口,前端项目 store 继续 +拥有项目列表和活动标签。使用锁定依赖中的 `same-file` 原生句柄判断目录身份, +不把所有路径强制转成小写,避免误合并支持大小写区分的两个目录。 + +新窗口创建把查找、预留和构建放在同一个异步锁内。连续打开同一目录时, +第二次请求会复用第一个窗口,即使它的前端尚未完成加载。已有项目的重复 +打开会显示窗口、解除最小化、聚焦,并通知所属前端切换到对应项目标签。 +例如窗口 A 的后台标签打开了项目 B,再从窗口 C 打开 B,应激活 A 中的 B; +不要只聚焦 A 而仍显示 A 的其他项目,也不要扫描各窗口持久化缓存来猜测归属。 + +前端先注册激活监听器,再登记恢复的项目标签;当前窗口打开项目也先登记, +所以不会绕过新窗口去重。项目关闭、初始化失败、新窗口构建失败和窗口销毁 +分别释放登记。初始路径校验失败也释放尚未登记为项目的预留,允许重试。 +销毁事件不能同步等待宿主异步锁,否则可能阻塞正在构建窗口的 UI 线程。 +远程和 WSL 协议路径不参与本地目录身份判断。 + ## 考虑过的备选方案 +- **按路径字符串或各窗口缓存查重**:字符串会漏掉链接与路径别名,缓存无法原子处理连续打开,也会残留已关闭窗口;因此窗口去重使用宿主原生身份登记。 - **继续只使用标题栏项目菜单**:可以复用现有入口,但多项目切换需要多次 打开菜单和识别项目,因此增加工作台内的直接切换条。 - **让 Tab Bar 成为原生窗口拖拽区**:会和项目按钮、关闭按钮争抢鼠标事件, @@ -58,6 +77,9 @@ Tab Bar 使用固定高度和水平溢出容器,不改变标题栏尺寸,也 持久化继续归属于 workspace/file-system store。固定尺寸和水平滚动使项目 数量增长时不会改变标题栏和工作台的布局。 +窗口去重额外保留每个本地项目的目录句柄,随项目或窗口关闭释放;原生恢复、 +聚焦和网络目录身份仍需 Windows 实机验证。 + 代价是单项目时用户看不到这条切换条,关闭动作只能在多项目工作台中使用; Tab Bar 目前不承载拖拽排序,若未来开放项目排序,必须复用 store 的排序 语义并补充相应的状态和并发测试。 @@ -68,6 +90,9 @@ Tab Bar 目前不承载拖拽排序,若未来开放项目排序,必须复用 - `bun --cwd windows/tauri run typecheck` - `./scripts/verify-windows-boundaries.sh` - `./scripts/verify-agent-notes.sh` +- `bun test windows/tauri/src/features/window/services/project-window-router.test.ts` +- `cargo test --manifest-path windows/tauri/src-tauri/Cargo.toml project_window_registry` +- Windows 手工验证:项目位于后台标签、窗口最小化、连续重复打开、目录别名、关闭重开及初始化失败重试。 测试覆盖可访问角色、项目切换、关闭按钮、异步状态清理、事件冒泡隔离、 项目顺序保持、单一活动项投影以及无项目/单项目/多项目的显示条件。 @@ -80,3 +105,8 @@ Tab Bar 目前不承载拖拽排序,若未来开放项目排序,必须复用 - `windows/tauri/src/features/window/stores/workspace-tabs.store.ts` - `windows/tauri/src/features/window/components/project-tab-bar.test.ts` - `windows/tauri/src/features/window/utils/project-tab-bar-model.test.ts` + +- `windows/tauri/src-tauri/src/project_windows.rs` +- `windows/tauri/src-tauri/src/project_window_registry.rs` +- `windows/tauri/src/features/window/services/project-window-routing.ts` +- `windows/tauri/src/features/window/services/project-window-router.ts` diff --git a/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md b/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md index 3719b5148..4ec3526af 100644 --- a/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md +++ b/.agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md @@ -1,4 +1,4 @@ -# Agent 笔记:Windows Git 提交图按分支名固定颜色 +# Agent 笔记:双端统一的 Git 提交图布局与分支颜色 状态:已实现 @@ -7,62 +7,49 @@ ## 先说结论 -Windows Git 提交图不再按"第几条泳道"轮换颜色,而是用泳道所代表分支的引用名 -(Java `String.hashCode`)决定颜色。分支换到别的泳道、历史分页或拓扑变化时, -同一条分支保持同一种颜色;没有引用名的泳道用一个确定性的兜底序号。 +macOS 和 Windows 的 Git 提交图现在使用同一套 IntelliJ 风格的永久图规则:先按引用优先级确定图头,再为每个图头分配稳定的图片段和颜色,最后根据提交顺序投影到屏幕泳道。这样本地分支新增提交时,不会因为沿用父分支的第一条泳道而和父分支混成同一种颜色;两个端的分支优先级、颜色身份和合并拓扑也保持一致。 + +Windows 仍保留现有六色调色板和 SVG 行渲染,只把 macOS 已落地的图头排序、永久布局和紧凑长边规则移植到 `git-graph-layout.ts`。今后修改提交图算法时,必须同时以 macOS 的实现和 IntelliJ 固定基准为准,不要重新引入按泳道创建颜色的算法。 ## 问题 -`windows/tauri/src/features/git/utils/git-graph-layout.ts` 原先把颜色按泳道槽位分配: -每新占一个槽位就 `colorIndex++`。泳道编号会随合并顺序、分页和可见提交集合变化, -于是同一条分支的颜色会跳变,用户看到的图与 macOS/IDEA 不一致。 +Windows 原先只在当前提交页上维护泳道槽位。第一父提交会直接继承当前泳道颜色,因此本地分支的提交和父分支的提交在分叉关系中可能继续使用同一个颜色。泳道编号还会随着合并顺序和页面数据变化,导致同一分支在不同历史窗口中的颜色或位置不稳定。 + +macOS 已经按 IntelliJ 的永久图规则处理了这个问题,但 Windows 仍是独立的槽位算法,导致同一仓库在两个端展示不同。 ## 决策 -- 颜色身份来自引用名,不来自泳道序号。每条提交解析其装饰(`parseGitDecorations`), - 按 IDEA 的优先级选出"主引用":`origin/main`/`origin/master` → 其他远程分支 → - 本地 `main`/`master` → 其他本地分支 → tag → HEAD。 -- 用主引用名的 Java `String.hashCode`(按 UTF-16 code unit 计算,`git-graph-colors.ts` - 的 `javaStringHashCode`)对现有 6 色调色板取模,得到 `colorIndex` - (`graphColorIndexForName`)。复用 `git-graph-row.tsx` 原有的调色板与线宽, - 不引入新颜色或色相算法。 -- 颜色在**泳道建立时**确定:非首个父提交占新槽位时用该提交的主引用名上色; - 首个父提交延续当前泳道颜色(与 macOS 的图头片段沿用同一颜色一致)。 -- 没有引用名的提交(合并的第二父、快照外的父提交等)用自增兜底序号 - (`fallbackColorIndex`),只保证同输入同输出,不承诺跨拓扑稳定。 -- 调色板、`graphColor` 与哈希函数集中到 - `windows/tauri/src/features/git/utils/git-graph-colors.ts`,供布局与行渲染共用。 +- **统一图头排序**:按 `origin/main`/`origin/master`、其他远程分支、本地 `main`/`master`、其他本地分支、tag、HEAD 的优先级排序;同类引用使用 IntelliJ 自然名称排序。`refs/heads/`、`refs/remotes/` 和 `refs/tags/` 在解析时先还原为展示名。 +- **统一永久布局**:先在完整的当前提交快照上按有序图头做非递归 DFS,生成与屏幕列无关的 layout index。已被更高优先级父分支占用的父提交保留父分支片段,新增本地图头从新的片段开始。 +- **统一颜色身份**:图头主片段使用主引用名的 Java `String.hashCode` 映射到 Windows 现有六色调色板;其他 DFS 片段使用 layout index。边使用两端 layout index 较大者所属片段的颜色,避免本地提交沿用父分支颜色。 +- **统一紧凑投影**:Windows 使用与 macOS compact 模式相同的 30 行长边阈值和 1 行端点保留规则。屏幕泳道只负责排版,不能再决定颜色身份。 +- **保持平台边界**:Rust Git 历史接口、JSON 契约和 macOS 的 native graph renderer 不变;Windows 只在自身布局投影层复用相同算法语义,并继续使用现有 SVG 组件绘制。 ## 考虑过的备选方案 -- **沿用 macOS 的整数 RGB → hue 方案(`GitGraphColor.swift`)**:能产生更多互不 - 相同的颜色,但会改变 Windows 现有的 6 色调色板与视觉语言,还需要跨平台同步 - 主题覆盖值。本次只要求颜色稳定,因此保留既有调色板,只改颜色身份来源。 -- **按提交 hash 取模**:实现最简单,但会让相邻的无关分支碰巧同色,历史一变化 - 颜色仍会跳。因此改为按引用名。 -- **未引用泳道也按某个假名哈希**:不如自增序号直观,也无法保证首帧稳定, - 故采用确定性兜底序号。 +- **只修复 Windows 的第一父颜色继承**:改动小,但无法解决合并顺序、分页和两个端布局规则不一致的问题,因此没有采用。 +- **继续按泳道序号轮换颜色**:实现简单,但泳道是屏幕布局结果,不是分支身份,历史窗口变化后颜色仍会跳变,因此没有采用。 +- **按提交 hash 取模**:可以让颜色看起来稳定,但相邻的无关提交可能碰巧同色,也无法体现父分支和本地分支的关系,因此没有采用。 +- **将算法下沉到 Rust Core**:当前 macOS 还需要 native graph 的打印元素与可见投影,而 Windows 使用不同的渲染模型;本次只统一算法语义,避免把平台绘制模型错误地塞入跨平台 Core。 ## 后果 -- 同一条命名分支在分页、筛选、拓扑变化后颜色不变;颜色只取决于引用名。 -- 6 色调色板下不同分支仍可能撞色(这是既有视觉约束,非回归)。 -- `GitGraphEdge.colorIndex` / `GitGraphRow.incomingLaneColors` 的语义从"泳道序号" - 变为"调色板槽位",取值 0–5 或兜底序号;未引用的兜底序号与命名分支的槽位 - 可能重叠。 -- 单仓库、单分支等简单历史行为不变(颜色仍是调色板第 0 色时可能的等价结果)。 +- Windows 本地分支在父分支之上新增提交时,提交节点和连接线可以与父分支清楚区分,解决 Issue #902 的主要体验问题。 +- 两端的图头优先级、自然名称排序、永久片段颜色和紧凑长边处理一致;同一历史在两个端不会再因为使用不同的槽位算法而出现明显分叉差异。 +- Windows 仍使用六色调色板,不同片段在颜色数量有限时可能撞色;这属于既有视觉约束,不代表片段身份被重新合并。 +- Windows 当前日志页没有 macOS 的完整仓库上下文投影能力,因此颜色稳定性范围是当前加载的提交快照;以后增加跨页/筛选永久图时,必须复用这里的永久布局入口,不能恢复槽位着色。 ## 验证 -- Windows 前端:`tsc --noEmit`;`bun test src/features/git`,其中 - `git-graph-layout.test.ts` 覆盖"同一分支名在不同拓扑落到不同泳道时颜色一致"、 - "未引用泳道确定性"、"Java `String.hashCode` 取值"与"主引用优先级选择"。 +- 既有 macOS 对照基准:`./scripts/verify-git-graph.sh` +- Windows 前端:`tsc --noEmit`;`bun test src/features/git` +- 代码边界和运行时资源:`./scripts/verify-service-boundaries.sh`、`./scripts/verify-runtime-bundle-immutability.sh` ## 适用范围 -- `windows/tauri/src/features/git/utils/git-graph-colors.ts` - `windows/tauri/src/features/git/utils/git-graph-layout.ts` +- `windows/tauri/src/features/git/utils/git-graph-colors.ts` - `windows/tauri/src/features/git/components/log/git-graph-row.tsx` - -不改变 Rust Core、JSON 契约或 macOS 实现;Windows 的布局仍是泳道槽位算法, -不是 macOS 的仓库级永久图。 +- `macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift` +- `macos/Sources/LitheGitModule/Services/GitGraphProjection.swift` +- `macos/Tests/LitheGitModuleTests/Fixtures/GitGraphIDEA/` diff --git a/.agents/notes/implemented/feature/2026-09-27-windows-spring-endpoints.md b/.agents/notes/implemented/feature/2026-09-27-windows-spring-endpoints.md new file mode 100644 index 000000000..ade5e33bf --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-27-windows-spring-endpoints.md @@ -0,0 +1,80 @@ +# Agent 笔记:Windows Spring 接口列表的索引与刷新边界 + +状态:已实现 + +## 先说结论 + +Windows 的 Spring Endpoints 列表展示已有 `spring.index` 返回的 HTTP 接口, +支持筛选并跳转到控制器源码。索引结果属于发起请求的项目;切换项目或关闭窗口后, +旧任务不能读取新项目的编辑器内容或覆盖新列表。刷新失败时保留同一项目上一次的 +成功结果,并明确提示它可能已经过期。 + +## 问题 + +原有 Windows Spring 适配层只保留配置和 Bean 信息,没有把 Core 已返回的接口 +集合交给工作台。增加列表后,用户还需要区分“未找到接口”“正在扫描”和“扫描失败”, +并能在控制器文件或整个源码目录被重命名后打开正确位置。 + +项目列表、编辑器内容和索引都按工作区存储。异步任务恢复执行时再读取全局活动 +项目,会读到切换后的状态。文件监听又可能只报告目录的旧路径和新路径,按 `.java` +后缀决定刷新会漏掉整个包的移动或删除。 + +## 决策 + +- 复用 Core 的 `spring.index` 和已有跨平台返回值,Windows 只负责状态、筛选、 + 列表展示与源码跳转。保留 Core 给出的稳定顺序和一基行列号,沿现有导航边界转换。 + 不在 React 中重新解析 Spring 注解,也不把此次接入扩展为另一套语言服务。 +- 索引请求开始时固定工作区 ID 和对应的文件、编辑器、Spring 存储。文件扫描和 + 元数据查找完成后分别检查任务是否仍有效;旧结果不能更新其他项目,也不能重新 + 创建已经关闭的工作区。工作区 ID 本身也是重新绑定的依据,不能只比较根目录字符串。 +- 状态使用 `idle/loading/ready/failed`。同根目录刷新保留成功结果,失败标记过期; + 切换根目录立即清空旧结果。存储的请求序号拒绝同项目中较早完成的过期请求。 +- 当前根目录下的创建、删除与全量重扫都触发去抖刷新;重命名由创建、删除两个事件 + 覆盖。现有事件不区分文件与目录,所以结构变更保守刷新;普通内容更新只响应 Spring + 相关文件,其他项目的事件不进入本项目索引。计时器与监听器由挂载的控制器统一清理。 +- 大列表复用 TanStack Virtual 和现有 ScrollArea。普通列表与虚拟列表使用相同行高; + HTTP 方法集合、路由、控制器和路径在窄面板中截断,完整值由行提示保留。 +- 索引失败提示使用固定分类和翻译,日志只记录分类;源码跳转失败仅记录操作名, + 不把宿主异常里的完整路径直接输出。 + +正确做法是“项目 A 开始扫描 → 用户切到 B → A 的后续请求被丢弃,B 独立更新”。 +不要在 A 的异步回调中调用全局活动存储的 `getState()`,也不要把文件夹重命名当作 +无关的非 Java 文件变化。 + +## 考虑过的备选方案 + +- **在前端另写注解解析器。** 已有 Core 操作提供两端一致的接口集合,重复解析会 + 产生不同的路由和位置,因此继续复用现有能力。 +- **每次刷新先清空列表。** 会在编辑源码时不断闪空,也无法区分刷新失败和零结果, + 因此只在根目录改变时清空。 +- **结构事件只检查文件后缀。** 无法识别目录中包含的控制器,已删除目录也不能再靠 + 文件查询补回类型,因此采用带去抖的保守刷新。 + +## 后果 + +接口搜索、源码定位和失败状态都有自动化测试,且不会因为项目切换混用存储。 +代价是无关文件的结构变更也可能触发一次刷新;去抖合并连续事件,普通文件内容更新 +仍按类型过滤。 + +本次范围为 Windows 本地盘符和 UNC 主工作区。WSL、远程和附加工作区根目录的接口 +浏览不在此次能力内。Core 现有 Java 源码索引的语义限制仍然存在;自定义组合注解、 +继承映射、计算得到的路径常量、Kotlin 和生成源码需要另行完善其所属的索引能力。 + +## 验证 + +- `cd windows/tauri && bun test src/features/spring src/features/keymaps/commands/view-command-actions.test.ts src/features/file-system/services/file-watcher-listener.test.ts` +- `cd windows/tauri && bun run typecheck` +- `node .agents/skills/write-stable-tests/scripts/verify-test-stability.mjs --platform windows` +- `./scripts/verify-windows-boundaries.sh` +- `./scripts/verify-shared-contracts.sh` +- `./scripts/verify-platform-feature-matrix.sh` +- `./scripts/verify-agent-notes.sh` +- Windows 实机验证:打开 Spring 项目,用命令面板进入接口列表,筛选并点击接口; + 重命名源码目录后再次跳转;扫描中切换项目;检查失败提示和超过 200 个接口时的滚动。 + +## 适用范围 + +- `windows/tauri/src/features/spring/` +- `windows/tauri/src/features/file-system/services/file-watcher-listener.ts` +- `windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx` +- `shared/contracts/rust-core-api.md` diff --git a/.agents/skills/develop-lithe/SKILL.md b/.agents/skills/develop-lithe/SKILL.md index 6a4a73c09..2bc96eb8b 100644 --- a/.agents/skills/develop-lithe/SKILL.md +++ b/.agents/skills/develop-lithe/SKILL.md @@ -152,6 +152,32 @@ change platform implementation paths are required by CI to update the JSON. A reviewer may add the `matrix-exempt` label only for a reviewed refactor with no user-observable change; explain that exception in the pull request. +### Mandatory pre-PR matrix gate + +Do not declare a platform change complete, commit it as ready for review, push +it, or create/update a pull request until the local equivalent of the CI change +gate has passed. First inspect the actual platform paths changed relative to the +PR base: + +```bash +git diff --name-only origin/preview...HEAD +``` + +When any platform implementation path listed above changed, run both checks: + +```bash +./scripts/verify-platform-feature-matrix-change.sh origin/preview HEAD +./scripts/verify-platform-feature-matrix.sh +``` + +Use the exact pull-request base and head SHA instead of `origin/preview` and +`HEAD` when those SHAs are available. If the change gate reports platform paths +without `shared/platform-feature-matrix.json`, stop and update the matrix source +and its generated Markdown/CSV views before proceeding. Do not use +`matrix-exempt` to bypass a user-visible behavior change. A user request to skip +optional tests does not waive this contract gate when the work is being prepared +for a pull request. + ## Follow the codebase's language conventions Apply the style used by surrounding files. Prefer descriptive names, focused diff --git a/.agents/skills/write-stable-tests/references/windows-and-rust.md b/.agents/skills/write-stable-tests/references/windows-and-rust.md index cde2cdbb0..ff2f1a1fa 100644 --- a/.agents/skills/write-stable-tests/references/windows-and-rust.md +++ b/.agents/skills/write-stable-tests/references/windows-and-rust.md @@ -38,7 +38,7 @@ report. One suite deadline covers compilation, enumeration, every test process, and the clean cache retry; a timeout writes the completed records before the runner exits. This isolation makes the exact hanging test visible. -SharedRust explicitly runs both `lithe-git-host` and `lithe-core`, keeping the +SharedRust explicitly runs `lithe-agent-host`, `lithe-git-host`, and `lithe-core`, keeping the native Git process/AskPass tests in `git-host-rust.json` and Core tests in `shared-rust.json`. Testing a Cargo package does not run its dependencies' own tests. macOS's `verify-rust-core.sh` runs the same native adapter timing lane. diff --git a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs index 6553844b8..ccfbe3f0b 100755 --- a/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs +++ b/.agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs @@ -255,7 +255,9 @@ export async function run( const suiteDeadlineReached = result.timedOut && testBudget.limitedBySuite; const status = result.timedOut ? "timeout" - : result.code === 0 && /running 0 tests/.test(result.stdout) + : result.code === 0 && + (/running 0 tests/.test(result.stdout) || + /test result: ok\. 0 passed; 0 failed; [1-9]\d* ignored/.test(result.stdout)) ? "skipped" : result.code === 0 ? "passed" diff --git a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 index 65dd6784f..845a3dd94 100644 --- a/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 +++ b/.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 @@ -165,6 +165,12 @@ if ($Scope -in @("All", "SharedRust")) { -TargetDirectory "rust/target" ` -Report (Join-Path $reportRoot "git-host-rust.json") + Invoke-TimedRustTests ` + -Manifest "rust/Cargo.toml" ` + -Package "lithe-agent-host" ` + -TargetDirectory "rust/target" ` + -Report (Join-Path $reportRoot "agent-host-rust.json") + Invoke-TimedRustTests ` -Manifest "rust/Cargo.toml" ` -Package "lithe-core" ` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 55e6d7b0d..d0cf13e2e 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -210,6 +210,8 @@ jobs: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 120000 --report .artifacts/test-stability/windows-documents.json -- src/features/editor/stores/editor-app.store.test.ts src/features/editor/services/document-external-change-workflow.test.ts src/features/editor/services/document-save-lifecycle.test.ts src/features/editor/services/document-watch-controller.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } bun test src/features/editor/lsp/language-server-navigation.test.ts src/features/editor/lsp/java-navigation-marker-loader.test.ts src/features/editor/engines/monaco/definition-link-scheduler.test.ts src/features/editor/engines/monaco/java-implementation-markers.test.ts src/features/editor/engines/monaco/java-run-markers.test.ts + node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-semantic-tokens.json -- src/platform/lsp-core-adapter.test.ts src/features/editor/lsp/lsp-client-semantic-tokens.test.ts src/features/editor/engines/monaco/semantic-token-provider.test.ts + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-editor-ownership.json -- src/features/editor/extensions/api.owner-navigation.test.ts if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-java-workspace.json -- src/features/editor/lsp/java-workspace-language-server.test.ts src/features/editor/lsp/java-workspace-change-scheduler.test.ts @@ -244,6 +246,10 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } } + - name: Test Windows Git path identity + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --suite-timeout-ms 60000 --report .artifacts/test-stability/windows-git-paths.json -- src/features/git/api/git-repo-api.test.ts src/utils/path-helpers.test.ts + - name: Test Git query recovery and isolation shell: pwsh timeout-minutes: 5 diff --git a/Package.swift b/Package.swift index c43290352..234edc6c7 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( .library(name: "LitheTerminalModule", targets: ["LitheTerminalModule"]), .library(name: "LitheDatabaseModule", targets: ["LitheDatabaseModule"]), .library(name: "LitheAIAssistanceModule", targets: ["LitheAIAssistanceModule"]), + .library(name: "LitheAgentConversationModule", targets: ["LitheAgentConversationModule"]), .library(name: "LitheExecutionModule", targets: ["LitheExecutionModule"]), .library(name: "LitheDebugModule", targets: ["LitheDebugModule"]), .library(name: "LitheLanguageIntelligenceModule", targets: ["LitheLanguageIntelligenceModule"]), @@ -81,6 +82,7 @@ let package = Package( .target(name: "LitheTerminalModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheTerminalModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheDatabaseModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheDatabaseModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheAIAssistanceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheAIAssistanceModule", swiftSettings: [.swiftLanguageMode(.v6)]), + .target(name: "LitheAgentConversationModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheAgentConversationModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheExecutionModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheExecutionModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheDebugModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheDebugModule", swiftSettings: [.swiftLanguageMode(.v6)]), .target(name: "LitheLanguageIntelligenceModule", dependencies: ["LitheModuleAPI", "LitheCoreContracts"], path: "macos/Sources/LitheLanguageIntelligenceModule", swiftSettings: [.swiftLanguageMode(.v6)]), @@ -104,6 +106,7 @@ let package = Package( "LitheTerminalModule", "LitheDatabaseModule", "LitheAIAssistanceModule", + "LitheAgentConversationModule", "LitheExecutionModule", "LitheDebugModule", "LitheLanguageIntelligenceModule", @@ -114,6 +117,7 @@ let package = Package( ], path: "macos/Sources/Lithe", resources: [ + .copy("Resources/AgentIcons"), .copy("Resources/MarkdownPreview"), .copy("Resources/SyntaxHighlighting"), .copy("Resources/WorkbenchBackgrounds") @@ -127,7 +131,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", .product(name: "Testing", package: "swift-testing")], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheAgentConversationModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", "LithePhpSupportModule", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") diff --git a/README.md b/README.md index b380df2e3..45bfc01ba 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Welcome to join the Lithe community, share your experience, ask questions, and f QR code for the Lithe QQ group - QR code for the Lithe WeChat group + QR code for the Lithe WeChat group diff --git a/README.zh-CN.md b/README.zh-CN.md index 2fa319f3c..c94d64702 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -43,7 +43,7 @@ Lithe QQ 群二维码 - Lithe 微信群二维码 + Lithe 微信群二维码 diff --git a/docs/assets/contact/wechat-group-qr-2026-09.png b/docs/assets/contact/wechat-group-qr-2026-09.png new file mode 100644 index 000000000..81dc3d270 Binary files /dev/null and b/docs/assets/contact/wechat-group-qr-2026-09.png differ diff --git a/docs/assets/contact/wechat-group-qr.png b/docs/assets/contact/wechat-group-qr.png deleted file mode 100644 index 1f1e93f92..000000000 Binary files a/docs/assets/contact/wechat-group-qr.png and /dev/null differ diff --git a/docs/ci-builds.md b/docs/ci-builds.md index f3a3e8602..ef96ea0f2 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -51,6 +51,12 @@ workflows. They identify the latest published preview, rather than an arbitrary PR. macOS preview jobs additionally expose their own artifact links before the combined rolling Release publishes. +Git 路径往返集成测试 `rust/lithe-core/tests/git_path_roundtrip.rs` 需要 Git 和 +Node.js 22.6+(用于直接加载实际前端 TypeScript 路径规范化函数),不需要安装 +Bun 或前端依赖。CI 复用计时脚本已使用的 runner Node.js,本地运行时需满足 +上述最低版本;该测试随 SharedRust 计时测试执行,结果写入现有 +`.artifacts/test-stability/` 报告。 + ## Build time and caches The September 12, 2026 investigation found two separate sources of delay: @@ -87,6 +93,24 @@ GitHub queue delays remain outside these build steps. Compare warm-cache runs with these baselines before claiming a measured improvement. A runner pool change should be evaluated separately if queueing continues to dominate. +### macOS Git 真实窗口性能采样 + +普通 `./scripts/test-macos.sh` 和 `./scripts/test-git-performance-baseline.sh` +默认跳过两个 WindowServer/display-link 真实窗口采样用例,继续运行 Git 图布局、 +离屏绘制和其他性能回归验证。真实窗口采样需要 macOS 14+ 和可用的桌面显示; +只在专门测量滚动帧率时显式开启: + +```bash +LITHE_RUN_GIT_COMPOSITOR_TESTS=1 \ + ./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh \ + -- --filter 'GitGraphPerformanceBaselineTests.*[wW]indowCompositorFrameSample' +``` + +此命令会短暂显示两个有标题、不透明的普通层级测试窗口,不强制激活程序或抢占 +焦点。用例在成功、跳过或超时后关闭窗口、停止显示链接,并恢复原激活策略。 +没有可用显示或没有收到显示链接回调时,用例输出未采样原因,不能将其计为真实 +帧率验证。不要在普通开发验证或无人值守 CI 中默认设置该环境变量。 + ### 独立工作树的本地编译 Git worktree 只共享 Git 对象,不共享各自的 `.artifacts` 目录。如果从一个 @@ -134,6 +158,14 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 以下目录不应直接复制或跨工作树共享: +- Agent CLI 的用户级安装与下载缓存:npm 的 global prefix/cache、Homebrew 的 + Cellar/Caskroom/cache、用户目录下 `.local/share/claude/versions`。它们由运行时 + `PATH` 和原安装器决定,不属于工作树;包版本、平台与架构由原安装器校验, + 没有工作树构建身份 stamp,任何复制阶段都禁止复用。注册表的 + `excludedResources.agent-cli-runtime` 记录此边界,脚本显式拒绝选择它。 + +- Agent 历史注释:平台偏好设置键 `lithe.agent-history.v1.` 保存收藏、自定义标题和隐藏状态,按标准化工作区与 Agent ID 隔离。它是用户可变状态,不受版本、平台、架构或工具链构建身份约束,不存在可验证的构建 stamp;Markdown 导出写到用户选择的位置。两者都禁止在任何复制阶段跨工作树复用,`excludedResources.agent-history-metadata` 由资源脚本显式拒绝。 + - `.artifacts/bun-tmp/`、下载或解压过程中的临时目录; - `.artifacts/jdtls/`、`.artifacts/jdk-*` 等可以由已验证下载重新生成的解压输出; - `.artifacts/editor/macos/` 和官方插件等尚未写入构建身份 stamp 的生成资源; diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 0cea6003d..1c6c29e25 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -1,19 +1,27 @@ id,area,feature group,capability,macOS implementation,macOS verification,Windows implementation,Windows verification,owner,verification,notes,macOS evidence,Windows evidence +agent-entry-discoverability,AI,Agent 对话,新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失,已实现,待验证,未实现,不适用,Agent,使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。,,macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift; macos/Sources/Lithe/Views/Agent/AgentConversationView.swift; macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift,windows/tauri/src/features +agent-conversation-history,AI,Agent 对话,CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出,已实现,待验证,未实现,不适用,Agent,macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。,,macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift; macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift; macos/Tests/LitheTests/AgentHistoryTests.swift,rust/lithe-agent-host/src/lib.rs +agent-context-usage,AI,Agent 对话,Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新,已实现,待验证,部分实现,待验证,Agent,macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift; macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/lib.rs; shared/fixtures/agent/acp-events-v1.json +agent-acp-conversation,AI,Agent 对话,可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理,已实现,待验证,部分实现,待验证,Agent,macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。,,macos/Sources/LitheAgentConversationModule; macos/Sources/Lithe/Views/Agent; rust/lithe-agent-host,rust/lithe-agent-host +agent-file-references,AI,Agent 对话,从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿,已实现,待验证,部分实现,待验证,Agent,macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。,,macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift; macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift; macos/Tests/LitheTests/AgentFileReferenceTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; rust/lithe-agent-host/src/tests.rs,rust/lithe-agent-host/src/prompt.rs; shared/fixtures/agent/acp-events-v1.json +agent-management,AI,Agent 对话,Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败,已实现,待验证,部分实现,待验证,Agent,在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。,,macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift; macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift; rust/lithe-agent-host/src/install.rs; rust/lithe-core/src/agent/mod.rs; rust/lithe-agent-host/src/npm-progress.mjs; rust/lithe-agent-host/src/cli_update.rs; macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift,rust/lithe-core/src/agent/mod.rs +agent-provider-configuration,AI,Agent 对话,Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联,已实现,待验证,部分实现,待验证,Agent,macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。,,macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift; macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift; macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift; macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift; macos/Tests/LitheTests/AgentProviderConfigurationTests.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift,rust/lithe-core/src/ai/agent_configuration.rs; shared/fixtures/agent/provider-configuration-v1.json workspace-open-switch,工作区,工作区生命周期,打开工作区与切换项目,已实现,待验证,已实现,待验证,Workspace,打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/workspace workspace-files,工作区,工作区生命周期,文件树与文件操作,已实现,待验证,已实现,待验证,Workspace,新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。,,macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/file-system workspace-document-sync,工作区,工作区生命周期,脏状态、保存与外部修改,已实现,待验证,已实现,待验证,Workspace,编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。,,macos/Sources/Lithe/Models/Editor; macos/Sources/Lithe/Services/Workspace,windows/tauri/src/features/editor; windows/tauri/src/features/file-system -workspace-tabs-projects,工作区,工作区生命周期,多项目与多标签,已实现,待验证,已实现,待验证,Workspace,同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/workspace; windows/tauri/src/features/tabs -editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs +workspace-tabs-projects,工作区,工作区生命周期,多项目与多标签,已实现,待验证,已实现,待验证,Workspace,同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。,,macos/Sources/Lithe/Views/Workspace; macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/workspace; windows/tauri/src/features/tabs; windows/tauri/src-tauri/src/project_windows.rs; windows/tauri/src-tauri/src/project_window_registry.rs; windows/tauri/src/features/window/services/project-window-router.ts +editor-text-tabs,编辑器,文本编辑,文本编辑与标签生命周期,已实现,待验证,已实现,待验证,Editor,打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; windows/tauri/src/features/tabs editor-language-basics,编辑器,文本编辑,语法高亮与编辑器模型,已实现,待验证,已实现,待验证,Editor,分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。,,macos/Sources/Lithe/Views/Editor; macos/Sources/Lithe/Models/Editor,windows/tauri/src/features/editor; frontend/editor; windows/tauri/src/features/editor/engines/monaco/theme.ts +editor-semantic-highlighting,编辑器,文本编辑,LSP 语义高亮,已实现,待验证,已实现,待验证,Editor / Language Tooling,打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。,Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。,macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift; macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift; frontend/editor/src/semantic-tokens.ts,windows/tauri/src/platform/lsp-core-adapter.ts; windows/tauri/src/features/editor/lsp/lsp-client.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts; windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts; windows/tauri/src/platform/lsp-core-adapter.test.ts editor-multiline-tabs,编辑器,文本编辑,多行标签与标签导航,已实现,待验证,已实现,待验证,Editor,打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。,,macos/Sources/Lithe/Views/Editor,windows/tauri/src/features/tabs search-quick-open,搜索,搜索与导航,快速打开文件与符号,已实现,待验证,已实现,待验证,Search,使用文件名、路径和符号查询,确认排序、键盘导航和打开位置。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Views/Workbench,windows/tauri/src/features/quick-open search-project,搜索,搜索与导航,项目范围文本搜索,已实现,待验证,已实现,待验证,Search,使用同一项目和查询 fixture 对比命中路径、行号、列号和结果排序。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search search-global,搜索,搜索与导航,全局命令与设置搜索,已实现,待验证,已实现,待验证,Search,使用快捷键搜索命令、设置和工作区内容,确认结果来源和跳转行为。,,macos/Sources/Lithe/Views/Search,windows/tauri/src/features/global-search; windows/tauri/src/features/command-palette search-replace,搜索,搜索与导航,项目替换与取消,已实现,待验证,已实现,待验证,Search,执行替换预览、确认替换和取消操作,确认未保存文件与异常文件不会被静默覆盖。,,macos/Sources/Lithe/Views/Search; macos/Sources/Lithe/Services/Language,windows/tauri/src/features/file-search -git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md +git-status-commit,版本控制,Git,状态、暂存与提交,已实现,待验证,已实现,待验证,Git,修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Services,windows/tauri/src/features/git; shared/contracts/application-boundary.md; windows/tauri/src/features/git/api/git-repository-path.ts; rust/lithe-core/tests/git_path_roundtrip.rs; shared/fixtures/git/windows-paths.json git-branches-remotes,版本控制,Git,分支、标签与远程,已实现,待验证,已实现,待验证,Git,创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-diff-review,版本控制,Git,Diff 与变更审查,已实现,待验证,已实现,待验证,Git,验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。,,macos/Sources/Lithe/Views/Git; macos/Sources/Lithe/Views/Diff,windows/tauri/src/features/git; windows/tauri/src/features/viewer -git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git +git-history,版本控制,Git,提交历史与图谱,已实现,待验证,已实现,待验证,Git,分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。,,macos/Sources/Lithe/Views/Git; macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift; macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift; macos/Sources/Lithe/Views/Git/GitGraphColor.swift,windows/tauri/src/features/git; windows/tauri/src/features/git/utils/git-graph-layout.ts; windows/tauri/src/features/git/utils/git-graph-colors.ts; windows/tauri/src/features/git/utils/git-graph-layout.test.ts git-rebase-stash,版本控制,Git,Rebase 与 Stash,已实现,待验证,已实现,待验证,Git,执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-worktrees,版本控制,Git,Worktree 管理,已实现,待验证,已实现,待验证,Git,列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git git-multi-repository-references,版本控制,Git,多仓库引用面板:分组、配色与引用操作,已实现,待验证,已实现,待验证,Git,在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。,,macos/Sources/Lithe/Views/Git,windows/tauri/src/features/git @@ -24,12 +32,14 @@ ai-chat-session,AI,AI 对话,对话会话与流式响应,未实现,不适用,已 ai-chat-history,AI,AI 对话,对话历史、置顶与归档,未实现,不适用,已实现,待验证,AI,Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/ai-chat-history-service.ts; windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx ai-chat-providers,AI,AI 对话,多 Provider 与工作区范围,未实现,不适用,已实现,待验证,AI,Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。,,macos/Sources/Lithe/Platform/MacOS/AI,windows/tauri/src/features/ai/services/providers; windows/tauri/src/features/ai/lib/ai-workspace-scope.ts java-runtime-discovery,Java,Java/Maven,JDK 与 Maven 工具链发现,已实现,待验证,已实现,待验证,Java / Maven,配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。,,macos/Sources/Lithe/Services/Java; macos/Sources/Lithe/Views/Run,windows/tauri/src/features/maven; windows/tauri/src/features/workspace +java-maven-project-settings,Java,Java/Maven,按项目配置 Maven 工具链、settings.xml 与本地仓库,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。,,macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift,windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx; windows/tauri/src/features/maven/stores/maven.store.ts; windows/tauri/src-tauri/src/maven.rs; windows/tauri/src-tauri/src/maven/settings_xml.rs; windows/tauri/src/features/maven/components/maven-detected-value.test.tsx; windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx java-maven-project,Java,Java/Maven,项目根、模块与源码集识别,已实现,待验证,已实现,待验证,Java / Maven,使用单模块、多模块和非标准源码目录 fixture 对比项目模型。,,macos/Sources/Lithe/Services/Java; shared/contracts/application-boundary.md,windows/tauri/src/features/maven; windows/tauri/src/features/workspace java-maven-profiles-dependencies,Java,Java/Maven,Profile 与依赖树,已实现,待验证,已实现,待验证,Java / Maven,"切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。",,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Java; macos/Sources/LitheExecutionModule/Services/MavenService.swift; macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift; rust/lithe-core/src/project/maven_dependency_tree.rs,windows/tauri/src/features/maven; windows/tauri/src-tauri/src/maven.rs; rust/lithe-core/src/project/maven_dependency_tree.rs java-maven-toolbar-tooltips,Java,Java/Maven,Maven 工具栏图标悬停说明,已实现,待验证,已实现,待验证,Java / Maven,在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。,,macos/Sources/Lithe/Views/Run/MavenView.swift; macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift; macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift,windows/tauri/src/features/maven/components/maven-pane.tsx java-build-diagnostics,Java,Java/Maven,构建输出与编译诊断,已实现,待验证,已实现,待验证,Java / Maven,使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。,,macos/Sources/Lithe/Views/Run; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/maven; windows/tauri/src/features/diagnostics spring-index,Java,Spring / MyBatis,Spring 配置、Bean 与 Endpoint 索引,已实现,待验证,已实现,待验证,Java / Spring,使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Run; shared/fixtures/spring,windows/tauri/src/features/spring; shared/fixtures/spring spring-navigation,Java,Spring / MyBatis,Spring 符号导航与代码关联,已实现,待验证,已实现,待验证,Java / Spring,从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。,,macos/Sources/Lithe/Application/Features; macos/Sources/Lithe/Views/Language,windows/tauri/src/features/spring +spring-endpoints-search,Java,Spring / MyBatis,Spring HTTP 接口筛选与源码跳转,已实现,待验证,已实现,待验证,Java / Spring,在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。,,macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift; macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift,windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx; windows/tauri/src/features/spring/hooks/use-spring-index.ts; windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx; windows/tauri/src/features/keymaps/commands/view-command-actions.ts mybatis-navigation,Java,Spring / MyBatis,MyBatis Mapper/XML 导航,已实现,待验证,已实现,待验证,Java / MyBatis,使用 Mapper/XML fixture 对比索引、导航、文件变更刷新和失效处理。,,macos/Sources/Lithe/Application/Features/MybatisFeatureModel.swift; macos/Sources/Lithe/Application/Composition/DocumentFeatureComposition.swift,windows/tauri/src/features/mybatis lsp-lifecycle,Java,语言服务,LSP/JDTLS 启动与工作区准备,已实现,待验证,已实现,待验证,Language Tooling,在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。,,macos/Sources/Lithe/Core/Language; macos/Sources/Lithe/Services/Language; rust/lithe-core/src/lsp/languages/jdt_configuration.rs,windows/tauri/src/features; windows/tauri/src-tauri/src/lsp.rs; rust/lithe-core/src/lsp/languages/jdt_configuration.rs; shared/fixtures/lsp lsp-completion-hover,Java,语言服务,补全与 Hover,已实现,待验证,已实现,待验证,Language Tooling,在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。,,macos/Sources/Lithe/Core/Language; shared/fixtures/lsp,windows/tauri/src/features; shared/fixtures/lsp @@ -59,8 +69,8 @@ outline-symbols,编辑器,代码结构,当前文件 Outline 与符号树,部分 references-pane,编辑器,代码结构,引用结果面板与引用跳转,已实现,待验证,已实现,待验证,Language Tooling,从符号发起引用查询,验证结果分组、文件定位、关闭和重新查询。,,macos/Sources/Lithe/Views/Language/JavaReferencesView.swift,windows/tauri/src/features/references diagnostics-workspace,可观测性,诊断与日志,工作区诊断与问题面板,已实现,待验证,已实现,待验证,Diagnostics,制造多个来源的错误和警告,验证筛选、排序、定位和清除。,,macos/Sources/Lithe/Application/Features/DiagnosticsFeatureModel.swift; macos/Sources/Lithe/Views/Language/JavaProblemsView.swift,windows/tauri/src/features/diagnostics; windows/tauri/src/features/logging; windows/tauri/src/ui/dialog.tsx diagnostics-export,可观测性,诊断与日志,诊断包导出与脱敏,已实现,待验证,已实现,待验证,Diagnostics,导出诊断包,确认敏感路径、凭据和大文件被正确脱敏或限制;检查预览说明和文件列表在确认弹窗内完整显示、长内容正常换行且不溢出。,,macos/Sources/Lithe/Views/Diagnostics/DiagnosticsExportSheet.swift; macos/Sources/Lithe/Services/Diagnostics,windows/tauri/src/features/diagnostics -application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift,windows/tauri/src/features/settings -keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,部分实现,待验证,Settings,修改快捷键、制造冲突并恢复默认,确认命令实际执行。,Windows 快捷键设置仍有占位交互,见 #718。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps +application-settings,设置,应用配置,应用、项目与运行设置,已实现,待验证,已实现,待验证,Settings,修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。,,macos/Sources/Lithe/LitheApp.swift; macos/Sources/Lithe/Views/App/SettingsView.swift; macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift; macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift,windows/tauri/src/features/settings +keymap-customization,设置,应用配置,快捷键查看与自定义,已实现,待验证,已实现,待验证,Settings,从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。,Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。,macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift; macos/Sources/Lithe/Models/Keymap,windows/tauri/src/features/keymaps; windows/tauri/src/features/settings/components/macos-settings-panels.tsx; windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx; windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx plugin-extension-management,扩展,插件与主题,插件、语言服务与主题管理,已实现,待验证,已实现,待验证,Extensions,启用、禁用和恢复插件/主题,确认启动加载、失败隔离和设置持久化。,,macos/Sources/Lithe/Views/App/PluginManagementView.swift; macos/Sources/Lithe/Platform/MacOS/Plugins,windows/tauri/src/extensions; windows/tauri/src/features/settings onboarding-welcome,启动与窗口,首次启动,欢迎页、项目入口与首次启动引导,已实现,待验证,已实现,待验证,Onboarding,清空首次启动状态,验证打开项目、克隆项目、最近项目和跳过引导。,,macos/Sources/Lithe/Views/App/WelcomeView.swift; macos/Sources/Lithe/Views/Workspace,windows/tauri/src/features/onboarding; windows/tauri/src/features/bootstrap command-palette,工作台,命令与布局,命令面板与全局动作,已实现,待验证,已实现,待验证,Workbench,搜索并执行打开面板、运行、Git 和设置命令,确认快捷键和不可用命令状态。,,macos/Sources/Lithe/Models/Keymap/LitheCommandCatalog.swift; macos/Sources/Lithe/Views/Search/SearchEverywhereView.swift,windows/tauri/src/features/command-palette diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index f085a94fb..e55e4495b 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -2,11 +2,11 @@ > 本页由 `shared/platform-feature-matrix.json` 自动生成。不要直接编辑本文件;新增或变更功能时更新源数据,再运行 `node scripts/generate-platform-feature-matrix.mjs`。 -- 最后复核:2026-09-24 +- 最后复核:2026-09-25 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:84 -- macOS:实现:✅ 71 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 74 待验证,— 10 不适用 -- Windows:实现:✅ 78 已实现,🟡 4 部分实现,❌ 2 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 82 待验证,— 2 不适用 +- 功能项:94 +- macOS:实现:✅ 81 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 3 平台专属;验证:✔️ 0 已验证,🔍 84 待验证,— 10 不适用 +- Windows:实现:✅ 82 已实现,🟡 8 部分实现,❌ 4 未实现,🧩 0 平台专属;验证:✔️ 0 已验证,🔍 90 待验证,— 4 不适用 ## 实现状态定义 @@ -29,6 +29,25 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。 +
+AI · 11 个能力点 + +| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | +| --- | --- | --- | --- | --- | --- | --- | +| Agent 对话 | **新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失**
agent-entry-discoverability | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift`、`macos/Sources/Lithe/Views/Agent/AgentConversationView.swift`、`macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | 使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。 | | +| Agent 对话 | **CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出**
agent-conversation-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift`、`macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift`、`macos/Tests/LitheTests/AgentHistoryTests.swift` | ❌ 未实现
— 不适用
`rust/lithe-agent-host/src/lib.rs` | Agent | macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。 | | +| Agent 对话 | **Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新**
agent-context-usage | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift`、`macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/lib.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。 | | +| Agent 对话 | **可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理**
agent-acp-conversation | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule`、`macos/Sources/Lithe/Views/Agent`、`rust/lithe-agent-host` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host` | Agent | macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。 | | +| Agent 对话 | **从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿**
agent-file-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift`、`macos/Tests/LitheTests/AgentFileReferenceTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`rust/lithe-agent-host/src/tests.rs` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。 | | +| Agent 对话 | **Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败**
agent-management | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift`、`macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift`、`rust/lithe-agent-host/src/install.rs`、`rust/lithe-core/src/agent/mod.rs`、`rust/lithe-agent-host/src/npm-progress.mjs`、`rust/lithe-agent-host/src/cli_update.rs`、`macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/agent/mod.rs` | Agent | 在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。 | | +| Agent 对话 | **Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联**
agent-provider-configuration | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift`、`macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift`、`macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift`、`macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift`、`macos/Tests/LitheTests/AgentProviderConfigurationTests.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-core/src/ai/agent_configuration.rs`、`shared/fixtures/agent/provider-configuration-v1.json` | Agent | macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。 | | +| AI 提交信息 | **Provider 配置与提交信息生成**
ai-commit-generation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift`、`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/ai-commit-service.ts`、`shared/contracts/ai-commit.md` | Git / AI | 用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。 | | +| AI 对话 | **对话会话与流式响应**
ai-chat-session | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/components/chat/ai-chat.tsx`、`windows/tauri/src/features/ai/services/ai-chat-service.ts` | AI | Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。 | | +| AI 对话 | **对话历史、置顶与归档**
ai-chat-history | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/ai-chat-history-service.ts`、`windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx` | AI | Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。 | | +| AI 对话 | **多 Provider 与工作区范围**
ai-chat-providers | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/providers`、`windows/tauri/src/features/ai/lib/ai-workspace-scope.ts` | AI | Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。 | | + +
+
工作区 · 7 个能力点 @@ -37,7 +56,7 @@ | 工作区生命周期 | **打开工作区与切换项目**
workspace-open-switch | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace` | Workspace | 打开多个项目并在项目之间切换,确认当前项目、文件树和编辑器状态正确。 | | | 工作区生命周期 | **文件树与文件操作**
workspace-files | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-system` | Workspace | 新建、移动、重命名、删除文件和目录,并确认相对路径与错误提示一致。 | | | 工作区生命周期 | **脏状态、保存与外部修改**
workspace-document-sync | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/Editor`、`macos/Sources/Lithe/Services/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/file-system` | Workspace | 编辑未保存文件、外部修改文件并重启应用,确认冲突、保存和恢复行为。 | | -| 工作区生命周期 | **多项目与多标签**
workspace-tabs-projects | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace`、`windows/tauri/src/features/tabs` | Workspace | 同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 | | +| 工作区生命周期 | **多项目与多标签**
workspace-tabs-projects | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace`、`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/workspace`、`windows/tauri/src/features/tabs`、`windows/tauri/src-tauri/src/project_windows.rs`、`windows/tauri/src-tauri/src/project_window_registry.rs`、`windows/tauri/src/features/window/services/project-window-router.ts` | Workspace | 同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。 | | | 项目浏览 | **项目文件树与资源打开**
file-explorer | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift`、`macos/Sources/Lithe/Models/Workspace` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/file-explorer`、`windows/tauri/src/features/sidebar` | Workspace | 浏览目录、展开/折叠、打开资源并在文件变更后刷新树。 | | | 项目浏览 | **项目依赖与模块浏览**
dependency-browser | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Workspace/DependencySidebarView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/sidebar` | Workspace / Java | 打开 Maven 项目依赖和模块树,验证导航、刷新和空项目状态。 | | | 远程开发 | **远程连接与远程路径工作区**
remote-workspace | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/remote`、`windows/tauri/src/features/file-system` | Remote | Windows 验证连接、密码提示、远程路径、断线和重连;macOS 需要补充产品入口或明确不支持。 | | @@ -45,12 +64,13 @@
-编辑器 · 11 个能力点 +编辑器 · 12 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 文本编辑 | **文本编辑与标签生命周期**
editor-text-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/tabs` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态。 | | +| 文本编辑 | **文本编辑与标签生命周期**
editor-text-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`windows/tauri/src/features/tabs` | Editor | 打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。 | | | 文本编辑 | **语法高亮与编辑器模型**
editor-language-basics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor`、`macos/Sources/Lithe/Models/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor`、`frontend/editor`、`windows/tauri/src/features/editor/engines/monaco/theme.ts` | Editor | 分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。 | | +| 文本编辑 | **LSP 语义高亮**
editor-semantic-highlighting | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift`、`macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift`、`frontend/editor/src/semantic-tokens.ts` | ✅ 已实现
🔍 待验证
`windows/tauri/src/platform/lsp-core-adapter.ts`、`windows/tauri/src/features/editor/lsp/lsp-client.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts`、`windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts`、`windows/tauri/src/platform/lsp-core-adapter.test.ts` | Editor / Language Tooling | 打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。 | Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。 | | 文本编辑 | **多行标签与标签导航**
editor-multiline-tabs | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/tabs` | Editor | 打开足够多文件触发多行标签,确认滚动、切换、关闭和活动文件保持。 | | | Markdown | **Markdown 预览与富文本渲染**
markdown-rendering | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Editor/MarkdownPreviewView.swift`、`macos/Sources/Lithe/Core/Ports/MarkdownRendering.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown` | Editor | 使用代码高亮、表格、Mermaid、链接和相对路径 fixture 对比渲染结果。 | | | Markdown | **图片导入与链接定位**
markdown-images-links | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Markdown`、`macos/Sources/Lithe/Platform/MacOS/MarkdownPreviewWebView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/editor/markdown`、`windows/tauri/src/features/viewer` | Editor | 验证本地图片、远程图片、相对链接和打开源文件行为。 | | @@ -80,10 +100,10 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 | | +| Git | **状态、暂存与提交**
git-status-commit | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Services` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`shared/contracts/application-boundary.md`、`windows/tauri/src/features/git/api/git-repository-path.ts`、`rust/lithe-core/tests/git_path_roundtrip.rs`、`shared/fixtures/git/windows-paths.json` | Git | 修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。 | | | Git | **分支、标签与远程**
git-branches-remotes | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 创建、切换、合并分支并查看标签和远程,确认冲突与认证失败可恢复。 | | | Git | **Diff 与变更审查**
git-diff-review | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/Lithe/Views/Diff` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/viewer` | Git | 验证新增、删除、重命名、二进制和多文件 Diff 的展示与定位;从源代码管理打开已修改和未跟踪文件的工作区 Diff 后保持静止,确认 Diff 不会自动关闭,且只在文件不再出现在 Git 状态中时关闭。 | | -| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方。 | | +| Git | **提交历史与图谱**
git-history | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git`、`macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift`、`macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift`、`macos/Sources/Lithe/Views/Git/GitGraphColor.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git`、`windows/tauri/src/features/git/utils/git-graph-layout.ts`、`windows/tauri/src/features/git/utils/git-graph-colors.ts`、`windows/tauri/src/features/git/utils/git-graph-layout.test.ts` | Git | 分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。 | | | Git | **Rebase 与 Stash**
git-rebase-stash | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 执行交互式 Rebase 和 Stash 保存/恢复,确认中断、冲突和继续操作。 | | | Git | **Worktree 管理**
git-worktrees | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 列出、创建、切换和删除 Worktree,确认路径、分支和安全检查。 | | | Git | **多仓库引用面板:分组、配色与引用操作**
git-multi-repository-references | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Git` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git` | Git | 在多仓库工作区打开 Git Log:确认按仓库分组、仓库配色、非活动仓库分组只读,点其它仓库的引用会切换活动仓库且只加载一次,Pull 弹窗可选择远程分支与策略。 | | @@ -101,29 +121,19 @@
-AI · 4 个能力点 - -| 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | -| --- | --- | --- | --- | --- | --- | --- | -| AI 提交信息 | **Provider 配置与提交信息生成**
ai-commit-generation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/CommitWorkflowCoordinator.swift`、`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/git/services/ai-commit-service.ts`、`shared/contracts/ai-commit.md` | Git / AI | 用相同 diff、规则和 Provider 配置比较请求计划、取消、错误和生成文本。 | | -| AI 对话 | **对话会话与流式响应**
ai-chat-session | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/components/chat/ai-chat.tsx`、`windows/tauri/src/features/ai/services/ai-chat-service.ts` | AI | Windows 验证新建会话、流式输出、取消和失败恢复;macOS 需要先定义产品范围。 | | -| AI 对话 | **对话历史、置顶与归档**
ai-chat-history | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/ai-chat-history-service.ts`、`windows/tauri/src/features/layout/components/sidebar/sidebar-history.tsx` | AI | Windows 验证历史加载、重命名、置顶、归档、删除和重启后持久化。 | | -| AI 对话 | **多 Provider 与工作区范围**
ai-chat-providers | ❌ 未实现
— 不适用
`macos/Sources/Lithe/Platform/MacOS/AI` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/ai/services/providers`、`windows/tauri/src/features/ai/lib/ai-workspace-scope.ts` | AI | Windows 验证 Provider 切换、凭据边界、工作区范围和模型错误提示。 | | - -
- -
-Java · 12 个能力点 +Java · 14 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | | Java/Maven | **JDK 与 Maven 工具链发现**
java-runtime-discovery | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`macos/Sources/Lithe/Views/Run` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。 | | +| Java/Maven | **按项目配置 Maven 工具链、settings.xml 与本地仓库**
java-maven-project-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx`、`windows/tauri/src/features/maven/stores/maven.store.ts`、`windows/tauri/src-tauri/src/maven.rs`、`windows/tauri/src-tauri/src/maven/settings_xml.rs`、`windows/tauri/src/features/maven/components/maven-detected-value.test.tsx`、`windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx` | Java / Maven | 在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。 | | | Java/Maven | **项目根、模块与源码集识别**
java-maven-project | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Services/Java`、`shared/contracts/application-boundary.md` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/workspace` | Java / Maven | 使用单模块、多模块和非标准源码目录 fixture 对比项目模型。 | | | Java/Maven | **Profile 与依赖树**
java-maven-profiles-dependencies | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Java`、`macos/Sources/LitheExecutionModule/Services/MavenService.swift`、`macos/Sources/Lithe/Platform/MacOS/Persistence/MacMavenDependencyOutputStore.swift`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src-tauri/src/maven.rs`、`rust/lithe-core/src/project/maven_dependency_tree.rs` | Java / Maven | 切换 Maven profile 并刷新依赖树,确认模块归属、顺序、版本冲突/重复/依赖管理标注和错误边界;在依赖节点超过 6,000 个的项目中确认依赖树完整加载。 | | | Java/Maven | **Maven 工具栏图标悬停说明**
java-maven-toolbar-tooltips | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/MavenView.swift`、`macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift`、`macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven/components/maven-pane.tsx` | Java / Maven | 在 macOS 和 Windows 的 Maven 工具窗口逐一悬停运行、执行目标、重新加载、跳过测试、折叠和设置图标,确认提示与动作一致、提示框宽度适配文字;再检查禁用按钮和窄窗口中的提示换行与边缘约束。 | | | Java/Maven | **构建输出与编译诊断**
java-build-diagnostics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run`、`macos/Sources/Lithe/Services/Diagnostics` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/maven`、`windows/tauri/src/features/diagnostics` | Java / Maven | 使用成功、编译失败和进程失败构建 fixture,对比诊断位置、输出和退出状态。 | | | Spring / MyBatis | **Spring 配置、Bean 与 Endpoint 索引**
spring-index | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Run`、`shared/fixtures/spring` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring`、`shared/fixtures/spring` | Java / Spring | 使用 Spring fixture 对比配置、Bean、Endpoint 索引、刷新和失效处理。 | | | Spring / MyBatis | **Spring 符号导航与代码关联**
spring-navigation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features`、`macos/Sources/Lithe/Views/Language` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring` | Java / Spring | 从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。 | | +| Spring / MyBatis | **Spring HTTP 接口筛选与源码跳转**
spring-endpoints-search | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift`、`macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx`、`windows/tauri/src/features/spring/hooks/use-spring-index.ts`、`windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx`、`windows/tauri/src/features/keymaps/commands/view-command-actions.ts` | Java / Spring | 在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。 | | | Spring / MyBatis | **MyBatis Mapper/XML 导航**
mybatis-navigation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Application/Features/MybatisFeatureModel.swift`、`macos/Sources/Lithe/Application/Composition/DocumentFeatureComposition.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/mybatis` | Java / MyBatis | 使用 Mapper/XML fixture 对比索引、导航、文件变更刷新和失效处理。 | | | 语言服务 | **LSP/JDTLS 启动与工作区准备**
lsp-lifecycle | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`macos/Sources/Lithe/Services/Language`、`rust/lithe-core/src/lsp/languages/jdt_configuration.rs` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`windows/tauri/src-tauri/src/lsp.rs`、`rust/lithe-core/src/lsp/languages/jdt_configuration.rs`、`shared/fixtures/lsp` | Language Tooling | 在两端启动真实 JDTLS,验证项目准备、重启、超时、取消和资源清理,并确认典型工作流后已安装程序包(macOS app bundle、Windows 安装目录)的文件清单不变。 | | | 语言服务 | **补全与 Hover**
lsp-completion-hover | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Core/Language`、`shared/fixtures/lsp` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features`、`shared/fixtures/lsp` | Language Tooling | 在相同 Java fixture 中验证补全、Hover、排序、超时和空结果。 | | @@ -194,8 +204,8 @@ | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | -| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;关闭后重开再检查。 | | -| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | 🟡 部分实现
🔍 待验证
`windows/tauri/src/features/keymaps` | Settings | 修改快捷键、制造冲突并恢复默认,确认命令实际执行。 | Windows 快捷键设置仍有占位交互,见 #718。 | +| 应用配置 | **应用、项目与运行设置**
application-settings | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/LitheApp.swift`、`macos/Sources/Lithe/Views/App/SettingsView.swift`、`macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift`、`macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/settings` | Settings | 修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。 | | +| 应用配置 | **快捷键查看与自定义**
keymap-customization | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift`、`macos/Sources/Lithe/Models/Keymap` | ✅ 已实现
🔍 待验证
`windows/tauri/src/features/keymaps`、`windows/tauri/src/features/settings/components/macos-settings-panels.tsx`、`windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx`、`windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx` | Settings | 从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。 | Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。 |
diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 019fbdf73..26547b07d 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1126,3 +1126,107 @@ "Fetch completed with no reference changes." = "Fetch completed with no reference changes."; "Maven Build" = "Maven Build"; "Hide Maven Build tool window" = "Hide Maven Build tool window"; + +// Verified CLI update outcomes +"CLI updated to %@" = "CLI updated to %@"; +"The installer reported an error, but the new CLI version was verified." = "The installer reported an error, but the new CLI version was verified."; +"Update log" = "Update log"; + +/* Agent session selectors */ +"Search models" = "Search models"; +"No matching models" = "No matching models"; +"Thinking level" = "Thinking level"; +"Speed" = "Speed"; +"Fast" = "Fast"; +"Approval mode" = "Approval mode"; +"Ask for approval" = "Ask for approval"; +"Approve for me" = "Approve for me"; +"Full access" = "Full access"; +"Always ask to edit external files and use the internet" = "Always ask to edit external files and use the internet"; +"Only ask for actions detected as potentially unsafe" = "Only ask for actions detected as potentially unsafe"; +"Unrestricted access to the internet and any file on your computer" = "Unrestricted access to the internet and any file on your computer"; +"Collaboration mode" = "Collaboration mode"; +"Default" = "Default"; +"Plan" = "Plan"; +"Low" = "Low"; +"Fast mode" = "Fast mode"; +"Plan before making changes" = "Plan before making changes"; + +// Agent file references +"Attach files" = "Attach files"; +"Drop files here" = "Drop files here"; +"Drag files here or click to choose files" = "Drag files here or click to choose files"; +"Remove file %@" = "Remove file %@"; +"Only local files can be attached to an Agent message." = "Only local files can be attached to an Agent message."; +"Attach up to 32 files per message." = "Attach up to 32 files per message."; +"The Agent is still responding in this conversation." = "The Agent is still responding in this conversation."; + +// Agent provider configuration +"Providers" = "Providers"; +"Use local configuration or add your own provider." = "Use local configuration or add your own provider."; +"No custom providers yet." = "No custom providers yet."; +"Current provider: %@" = "Current provider: %@"; +"Local %@ configuration" = "Local %@ configuration"; +"Confirm deletion" = "Confirm deletion"; +"Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings." = "Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings."; +"Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation." = "Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation."; +"Edit provider" = "Edit provider"; +"Delete provider" = "Delete provider"; +"Add provider" = "Add provider"; +"Provider name" = "Provider name"; +"Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields." = "Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields."; +"Format JSON" = "Format JSON"; +"Invalid JSON. Check the configuration format." = "Invalid JSON. Check the configuration format."; +"Install ACP adapters, check the runtime, and manage local or custom providers." = "Install ACP adapters, check the runtime, and manage local or custom providers."; +"Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "Check the configuration format, API URL and model. Each configuration must be under 64 KiB."; +"Authentication must be a JSON object containing an OPENAI_API_KEY string." = "Authentication must be a JSON object containing an OPENAI_API_KEY string."; +"Enter an API key. CLI login credentials cannot be used as an API key." = "Enter an API key. CLI login credentials cannot be used as an API key."; +"Enter a provider name." = "Enter a provider name."; +"Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments." = "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments."; +"Codex Agents require the Responses API. Claude Agents require an Anthropic API key." = "Codex Agents require the Responses API. Claude Agents require an Anthropic API key."; +"Wait for the Agent operation to finish before changing providers." = "Wait for the Agent operation to finish before changing providers."; +"Provider configuration parsing is unavailable." = "Provider configuration parsing is unavailable."; +"No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider." = "No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider."; +"Choose a local or custom provider in the Agent panel settings." = "Choose a local or custom provider in the Agent panel settings."; +"This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings." = "This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings."; +"The credential store version is unsupported. Existing credentials were preserved." = "The credential store version is unsupported. Existing credentials were preserved."; +"Add" = "Add"; +"Agent provider" = "Agent provider"; +"Choose a local or custom provider so the Agent can sign in." = "Choose a local or custom provider so the Agent can sign in."; +"Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)." = "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)."; +/* Agent conversation history */ +"All conversations" = "All conversations"; +"Favorites" = "Favorites"; +"Removed conversations" = "Removed conversations"; +"Confirm deletion" = "Confirm deletion"; +"Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept." = "Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept."; +"%d selected" = "%d selected"; +"%d conversations" = "%d conversations"; +"%d messages" = "%d messages"; +"Select" = "Select"; +"Search conversation titles or IDs…" = "Search conversation titles or IDs…"; +"History filter" = "History filter"; +"Deselect all" = "Deselect all"; +"Select all" = "Select all"; +"Manage selected" = "Manage selected"; +"Export conversations" = "Export conversations"; +"Add to favorites" = "Add to favorites"; +"Remove from favorites" = "Remove from favorites"; +"Restore conversations" = "Restore conversations"; +"Remove from history" = "Remove from history"; +"Toggle favorite" = "Toggle favorite"; +"Removed conversations are hidden only in Lithe. The Agent's original records are kept." = "Removed conversations are hidden only in Lithe. The Agent's original records are kept."; +"Exporting conversations…" = "Exporting conversations…"; +"Loading history…" = "Loading history…"; +"No matching conversations" = "No matching conversations"; +"Try another title or session ID." = "Try another title or session ID."; +"Conversations for this project and Agent appear here." = "Conversations for this project and Agent appear here."; +"Conversation title" = "Conversation title"; +"Save title" = "Save title"; +"Copy session ID" = "Copy session ID"; +"Rename conversation" = "Rename conversation"; +"Select conversation" = "Select conversation"; +"Back" = "Back"; +"Context usage" = "Context usage"; +"Context: %@" = "Context: %@"; +"%@ · %@ / %@ context tokens" = "%@ · %@ / %@ context tokens"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index ae347b1bb..6c80e14da 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2189,3 +2189,272 @@ "Configure project JDK and Maven…" = "配置项目 JDK 和 Maven…"; "Run configurations inherit the project JDK and Maven unless overridden below." = "运行配置默认继承项目 JDK 和 Maven,可在下方为当前配置单独覆盖。"; "These overrides affect only this run configuration. Clear a path to inherit the project environment." = "以下覆盖值仅影响当前运行配置。清空路径可恢复继承项目环境。"; + +/* Agent panel */ +"Agent" = "Agent"; +"Agent Settings" = "Agent 设置"; +"Agent Settings…" = "Agent 设置…"; +"Back to the conversation" = "返回对话"; +"Open Agent Settings" = "打开 Agent 设置"; +"Conversation history" = "对话历史"; +"No earlier conversations" = "没有更早的对话"; +"Refresh history" = "刷新历史"; +"New conversation" = "新对话"; +"Close conversation" = "关闭对话"; +"Untitled conversation" = "未命名对话"; +"Choose an Agent" = "选择 Agent"; +"No Agent is set up yet" = "尚未设置 Agent"; +"No Agent is ready yet. Open the panel settings to install an Agent and fetch its local configuration." = "还没有可用的 Agent。请打开面板设置,安装 Agent 并获取它的本机配置。"; +"Agent conversation is turned off. Turn it on in the panel settings to send messages." = "Agent 对话功能已关闭。请在面板设置中开启后再发送消息。"; +"The Agent module is still starting. Try again in a moment." = "Agent 模块仍在启动,请稍后再试。"; +"The conversation is still being prepared. Try again in a moment." = "对话仍在准备中,请稍后再试。"; +"Set the custom Agent's executable in the panel settings." = "请在面板设置中填写自定义 Agent 的可执行文件。"; +"Fetch this Agent's local configuration in the panel settings." = "请在面板设置中获取该 Agent 的本机配置。"; +"Your local configuration has no API key for this Agent. Add one, then fetch the configuration again." = "本机配置中没有该 Agent 的 API 密钥。请先添加,然后重新获取配置。"; +"The Agent is not running. Connect to start a conversation." = "Agent 未运行。请先连接再开始对话。"; +"The previous Agent is still stopping. Try again shortly." = "上一个 Agent 仍在停止,请稍后重试。"; +"This Agent cannot reopen earlier conversations. Start a new conversation." = "该 Agent 无法重新打开更早的对话,请新建对话。"; +"Ask the Agent about this workspace" = "向 Agent 询问这个工作区"; +"It can read files, run commands and edit code after asking for permission." = "它可以在征得你的许可后读取文件、运行命令和修改代码。"; +"Ask the Agent, Enter to send, Shift-Enter for a new line" = "向 Agent 提问,Enter 发送,Shift-Enter 换行"; +"Send" = "发送"; +"Stop" = "停止"; +"Deny" = "拒绝"; +"Thinking…" = "思考中…"; +"Loading conversation…" = "正在加载对话…"; +"Starting the Agent…" = "正在启动 Agent…"; +"The Agent process starts when this panel opens." = "打开此面板时才会启动 Agent 进程。"; +"The Agent could not start" = "Agent 无法启动"; +"The Agent is waiting for your permission" = "Agent 正在等待你的许可"; +"Permission required" = "需要许可"; +"code" = "代码"; +"%lld tool calls" = "%lld 次工具调用"; +"%lld running" = "%lld 个运行中"; +"%lld failed" = "%lld 个失败"; + +/* Agent panel settings */ +"Agent conversation" = "Agent 对话"; +"Enable Agent conversation" = "启用 Agent 对话"; +"An Agent starts when you open this panel in a project. Turning this off stops every Agent process." = "在项目中打开此面板时才会启动 Agent。关闭后会停止所有 Agent 进程。"; +"Only API keys are used. The Agent's own account sign-in, such as a ChatGPT login, is never used." = "只使用 API 密钥登录,不会使用 Agent 自带的账号登录(例如 ChatGPT 登录)。"; +"Checking Node.js and npm…" = "正在检测 Node.js 和 npm…"; +"Not found" = "未找到"; +"Your login shell did not report a PATH, so only Lithe's own PATH was searched." = "登录 shell 没有返回 PATH,因此只搜索了 Lithe 自身的 PATH。"; +"Agents run on the Node.js installed on this Mac. Install or update Node.js yourself, then check again." = "Agent 运行在这台 Mac 上已安装的 Node.js 之上。请自行安装或升级 Node.js,然后重新检测。"; +"Installed %@" = "已安装 %@"; +"Installed %@ · update to %@ available" = "已安装 %@ · 可更新到 %@"; +"Not installed · %@" = "未安装 · %@"; +"Installing with npm…" = "正在用 npm 安装…"; +"Working…" = "处理中…"; +"Not used" = "不使用"; +"No %@ provider yet" = "还没有 %@ 服务商"; +"Manage…" = "管理…"; +"Custom Agent" = "自定义 Agent"; +"Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway." = "运行你自行安装的其他 ACP Agent。它必须支持通过自定义 API 密钥网关登录。"; +"Agent executable" = "Agent 可执行文件"; +"Arguments (one per line)" = "参数(每行一个)"; +"Node.js %lld or later was not found. Install it, then check again." = "未找到 Node.js %lld 或更高版本。请安装后重新检测。"; +"Node.js %lld or later is required; found %@." = "需要 Node.js %lld 或更高版本,当前为 %@。"; +"npm was not found. It is normally installed with Node.js." = "未找到 npm。它通常随 Node.js 一起安装。"; +"%@ was not found. Install it (for example `%@`), then check again." = "未找到 %@。请先安装(例如 `%@`),然后重新检测。"; +"%@ %@ or later is required; found %@. Update it, then check again." = "需要 %@ %@ 或更高版本,当前为 %@。请升级后重新检测。"; + +/* AI providers */ +"AI Providers" = "AI 服务商"; +"AI providers" = "AI 服务商"; +"Add an AI provider in AI Providers first." = "请先在“AI 服务商”中添加服务商。"; +"Manage AI Providers…" = "管理 AI 服务商…"; +"Commit message provider" = "提交信息服务商"; +"Commit messages use" = "提交信息使用"; +"Used by: %@" = "使用者:%@"; +"Not used yet." = "尚未被使用。"; +"commit messages" = "提交信息"; +"Agents" = "Agent 管理"; +"Provider details" = "服务商详情"; +"Saved" = "已保存"; +"Detected configurations" = "检测到的配置"; +"Unnamed provider" = "未命名服务商"; +"Agent list" = "Agent 列表"; +"Checking…" = "检测中…"; +"Unchecked" = "未检测"; +"Not set" = "未设置"; +"Custom" = "自定义"; +"Turning this off stops every Agent process." = "关闭后会停止所有 Agent 进程。"; +"ACP adapter" = "ACP 适配器"; +"Lithe installs the ACP adapter package, not the vendor CLI. The two are independent and share the CLI's configuration." = "Lithe 安装的是 ACP 适配器包,而不是厂商的 CLI。两者相互独立,并共用 CLI 的配置。"; +"Lithe has not yet completed a real conversation with this adapter version." = "Lithe 尚未用这个适配器版本完成过真实对话。"; +"Runs the %@ installed on this Mac through the %@ adapter." = "通过 %2$@ 适配器驱动这台 Mac 上已安装的 %1$@。"; +"Version status" = "版本状态"; +"Pinned: %@ · Local: %@" = "固定版本:%@ · 本地:%@"; +"Not installed" = "未安装"; +"Not installed. Install it to start using this Agent." = "尚未安装。安装后即可使用该 Agent。"; +"Fix the failing checks below before installing." = "请先解决下方未通过的检测项,再进行安装。"; +"An update is available." = "有可用更新。"; +"Up to date." = "已是最新版本。"; +"Preflight" = "预检"; +"Node.js %lld or later" = "Node.js %lld 或更高版本"; +"%@ %@ or later" = "%@ %@ 或更高版本"; +"%@ is not installed. Lithe installs it with your npm into its application data." = "%@ 尚未安装。Lithe 会用你的 npm 把它安装到应用数据目录。"; +"Signed in through the ACP gateway with this provider's API key." = "使用该服务商的 API 密钥通过 ACP 网关登录。"; +"Choose an AI provider below so the Agent can sign in." = "请在下方选择 AI 服务商,Agent 才能登录。"; +"Copy command" = "复制命令"; +"Launch" = "启动方式"; +"Local configuration" = "本机配置"; +"Not fetched yet." = "尚未获取。"; +"Fetch local configuration" = "一键获取本机配置"; +"Fetch again" = "重新获取"; +"Unlink" = "取消关联"; +"Use %@ configuration" = "使用 %@ 配置"; +"Signed in through the ACP gateway with the API key from your local configuration." = "使用本机配置中的 API 密钥通过 ACP 网关登录。"; +"Fetch your local configuration so the Agent can sign in." = "请获取本机配置,Agent 才能登录。"; +"Reads the API URL, model and key from ~/.codex/config.toml and auth.json. Edit those files to change them, then fetch again." = "从 ~/.codex/config.toml 和 auth.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。"; +"Reads the API URL, model and key from ~/.claude/settings.json and ~/.claude.json. Edit those files to change them, then fetch again." = "从 ~/.claude/settings.json 和 ~/.claude.json 读取 API 地址、模型和密钥。需要修改时请编辑这些文件,然后重新获取。"; +"A custom Agent can follow either your Codex or your Claude configuration." = "自定义 Agent 可以跟随你本机的 Codex 或 Claude 配置。"; +"No %@ configuration was found on this Mac." = "这台 Mac 上没有找到 %@ 配置。"; +"%@ now follows your local %@ configuration." = "%@ 现在跟随你本机的 %@ 配置。"; +"Install ACP adapters, check the runtime, and follow your local CLI configuration." = "安装 ACP 适配器、检测运行环境,并跟随你本机 CLI 的配置。"; +"Working with npm…" = "npm 正在处理…"; +"Install Node.js and npm before installing the adapter." = "请先安装 Node.js 和 npm,再安装适配器。"; +"Send a message to %@" = "向 %@ 发送消息"; +"Choose an Agent to start" = "选择一个 Agent 开始"; +"Edits" = "编辑"; +"Context" = "上下文"; +"Switch Agent" = "切换 Agent"; +"File context" = "文件上下文"; +"Attachments are not supported yet" = "暂不支持附件"; +"File context is not supported yet" = "暂不支持文件上下文"; +"Attach file" = "附件"; +"Message the Agent" = "向 Agent 发送消息"; +"Search conversation" = "搜索对话"; +"Conversation tabs" = "会话标签"; +"Close search" = "关闭搜索"; +"No matching messages" = "没有匹配的消息"; +"Context usage is not reported by this Agent" = "此 Agent 尚未提供上下文用量"; +"Project files" = "项目文件"; +"Stopping…" = "正在停止…"; +"Reconnect" = "重新连接"; +"Show tool details" = "展开工具详情"; +"Hide tool details" = "收起工具详情"; +"Wait for the Agent configuration to finish updating." = "请等待 Agent 配置更新完成。"; +"This file is outside the current project." = "此文件不在当前项目中。"; +"Input" = "输入"; +"Result" = "结果"; +"Output" = "输出"; +"More session settings" = "更多会话设置"; + +"Downloaded %@ · %@/s" = "已下载 %@ · %@/秒"; +"Elapsed %lld s" = "已用时 %lld 秒"; +"Waiting for download data. Check your network or proxy if this continues." = "正在等待下载数据。如果持续无进展,请检查网络或代理。"; +"Stopping installation…" = "正在停止安装…"; +"Preparing download…" = "正在准备下载…"; +"Downloading packages…" = "正在下载软件包…"; +"Installing packages…" = "正在安装软件包…"; + +"Updating CLI…" = "正在更新 CLI…"; +"Native installer" = "原生安装器"; +"Unknown source" = "未知来源"; +"Installation: %@" = "安装来源:%@"; +"Update this CLI using its original installer, then check again." = "请使用原安装器更新此 CLI,然后重新检测。"; +"Switch to the Node.js/npm environment that installed this CLI, update it there, then check again." = "请切换到安装此 CLI 的 Node.js/npm 环境,在该环境中更新后重新检测。"; + +// Verified CLI update outcomes +"CLI updated to %@" = "CLI 已更新至 %@"; +"The installer reported an error, but the new CLI version was verified." = "安装器报告了错误,但已确认新 CLI 版本可用。"; +"Update log" = "更新日志"; + +/* Agent session selectors */ +"Search models" = "搜索模型"; +"No matching models" = "没有匹配的模型"; +"Thinking level" = "思考强度"; +"Speed" = "速度"; +"Fast" = "快速"; +"Approval mode" = "权限模式"; +"Ask for approval" = "请求批准"; +"Approve for me" = "替我审批"; +"Full access" = "完全访问"; +"Always ask to edit external files and use the internet" = "修改工作区外文件或访问网络时先请求批准。"; +"Only ask for actions detected as potentially unsafe" = "自动审批,仅在检测到潜在风险时请求你的确认。"; +"Unrestricted access to the internet and any file on your computer" = "允许访问网络和电脑上的所有文件。"; +"Collaboration mode" = "协作模式"; +"Default" = "默认"; +"Plan" = "计划"; +"Low" = "低"; +"Fast mode" = "快速模式"; +"Plan before making changes" = "先制定计划,再进行修改。"; + +// Agent file references +"Attach files" = "添加文件"; +"Drop files here" = "拖放文件到这里"; +"Drag files here or click to choose files" = "拖入文件,或点击选择文件"; +"Remove file %@" = "移除文件 %@"; +"Only local files can be attached to an Agent message." = "Agent 消息仅支持添加本地文件。"; +"Attach up to 32 files per message." = "每条消息最多添加 32 个文件。"; +"The Agent is still responding in this conversation." = "Agent 仍在回复当前对话。"; + +// Agent provider configuration +"Providers" = "供应商管理"; +"Use local configuration or add your own provider." = "使用本机配置或添加自定义供应商。"; +"No custom providers yet." = "尚未添加自定义供应商。"; +"Current provider: %@" = "当前供应商:%@"; +"Local %@ configuration" = "使用本机 %@ 配置"; +"Confirm deletion" = "确认删除"; +"Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings." = "确定删除此供应商和已保存的 API 密钥吗?使用它的 Agent 将取消关联,设置中的 AI 服务商也会移除此项。"; +"Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation." = "本机配置跟随 CLI 文件。自定义供应商保存在 Lithe 中,不修改本机文件。切换会重启当前项目的 Agent 连接,可重新打开历史会话继续对话。"; +"Edit provider" = "编辑供应商"; +"Delete provider" = "删除供应商"; +"Add provider" = "添加供应商"; +"Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields." = "仅保存 API 地址、模型和 API 密钥,其他 CLI 选项继续沿用本机配置。密钥与设置分开存储。编辑时显示这些已保存字段的配置模板。"; +"Format JSON" = "格式化 JSON"; +"Invalid JSON. Check the configuration format." = "JSON 无效,请检查配置格式。"; +"Install ACP adapters, check the runtime, and manage local or custom providers." = "安装 ACP 适配器、检查运行环境,管理本机配置或自定义供应商。"; +"Check the configuration format, API URL and model. Each configuration must be under 64 KiB." = "请检查配置格式、API 地址和模型,每份配置不得超过 64 KiB。"; +"Authentication must be a JSON object containing an OPENAI_API_KEY string." = "认证配置必须是包含 OPENAI_API_KEY 字符串的 JSON 对象。"; +"Enter an API key. CLI login credentials cannot be used as an API key." = "请输入 API 密钥,CLI 账号登录凭据不能作为 API 密钥使用。"; +"Enter a provider name." = "请输入供应商名称。"; +"Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments." = "请输入 HTTP 或 HTTPS API 地址,不能包含账号、查询参数或片段。"; +"Codex Agents require the Responses API. Claude Agents require an Anthropic API key." = "Codex Agent 需要 Responses API,Claude Agent 需要 Anthropic API 密钥。"; +"Wait for the Agent operation to finish before changing providers." = "请等待 Agent 当前操作完成后再切换供应商。"; +"Provider configuration parsing is unavailable." = "供应商配置解析暂不可用。"; +"No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider." = "尚无可用的 Agent,请在面板设置中安装 Agent,并选择本机配置或自定义供应商。"; +"Choose a local or custom provider in the Agent panel settings." = "请在 Agent 面板设置中选择本机配置或自定义供应商。"; +"This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings." = "此 Agent 的供应商未配置 API 密钥,请更新本机配置或在面板设置中编辑自定义供应商。"; +"The credential store version is unsupported. Existing credentials were preserved." = "凭据存储版本暂不支持,已保留原有凭据。"; +"Add" = "添加"; +"Agent provider" = "Agent 供应商"; +"Choose a local or custom provider so the Agent can sign in." = "请选择本机配置或自定义供应商,Agent 才能登录。"; +"Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”)." = "TOML 格式无效,请使用直引号(\"),不要使用智能引号(“ ”)。"; +/* Agent conversation history */ +"All conversations" = "全部会话"; +"Favorites" = "收藏"; +"Removed conversations" = "已移除的会话"; +"Confirm deletion" = "确认删除"; +"Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept." = "确定要从历史记录中移除 %d 个会话吗?可在「已移除的会话」中恢复,Agent 的原始记录会保留。"; +"%d selected" = "已选 %d 项"; +"%d conversations" = "共 %d 个会话"; +"%d messages" = "%d 条消息"; +"Select" = "多选"; +"Search conversation titles or IDs…" = "搜索会话标题或 ID…"; +"History filter" = "历史筛选"; +"Deselect all" = "取消全选"; +"Select all" = "全选"; +"Manage selected" = "管理所选会话"; +"Export conversations" = "导出会话"; +"Add to favorites" = "加入收藏"; +"Remove from favorites" = "取消收藏"; +"Restore conversations" = "恢复会话"; +"Remove from history" = "从历史列表移除"; +"Toggle favorite" = "切换收藏"; +"Removed conversations are hidden only in Lithe. The Agent's original records are kept." = "移除仅隐藏 Lithe 中的会话,Agent 的原始记录仍保留。"; +"Exporting conversations…" = "正在导出会话…"; +"Loading history…" = "正在加载历史…"; +"No matching conversations" = "没有匹配的会话"; +"Try another title or session ID." = "试试其他标题或会话 ID。"; +"Conversations for this project and Agent appear here." = "此项目和 Agent 的会话会显示在这里。"; +"Conversation title" = "会话标题"; +"Save title" = "保存标题"; +"Copy session ID" = "复制会话 ID"; +"Rename conversation" = "重命名会话"; +"Select conversation" = "选择会话"; +"Context usage" = "上下文用量"; +"Context: %@" = "上下文: %@"; +"%@ · %@ / %@ context tokens" = "%@ · %@ / %@ 上下文 token"; diff --git a/macos/Sources/Lithe/Application/Composition/AppServices.swift b/macos/Sources/Lithe/Application/Composition/AppServices.swift index 6690cfe15..ebc41e6e3 100644 --- a/macos/Sources/Lithe/Application/Composition/AppServices.swift +++ b/macos/Sources/Lithe/Application/Composition/AppServices.swift @@ -44,6 +44,9 @@ final class AppServices { let databaseSecureStore: any SecureStore let diagnosticsExportService: DiagnosticsExportService let credentialResolver: any AIProviderCredentialResolver + /// Detects Node.js and installs ACP adapters for the Agents settings page. + let agentManagement: any AgentManagementService + let agentProviderConfigurationParser: any AgentProviderConfigurationParsing let aiConfigurationSources: [any AIConfigurationSource] let recentProjectsStore: RecentProjectsStore let workspaceSessionStore: WorkspaceSessionStore @@ -82,6 +85,8 @@ final class AppServices { databaseSecureStore: any SecureStore, diagnosticsExportService: DiagnosticsExportService, credentialResolver: any AIProviderCredentialResolver, + agentManagement: any AgentManagementService = UnavailableAgentManagementService(), + agentProviderConfigurationParser: any AgentProviderConfigurationParsing = UnavailableAgentProviderConfigurationParser(), aiConfigurationSources: [any AIConfigurationSource], recentProjectsStore: RecentProjectsStore, workspaceSessionStore: WorkspaceSessionStore, @@ -128,6 +133,8 @@ final class AppServices { self.databaseSecureStore = databaseSecureStore self.diagnosticsExportService = diagnosticsExportService self.credentialResolver = credentialResolver + self.agentManagement = agentManagement + self.agentProviderConfigurationParser = agentProviderConfigurationParser self.aiConfigurationSources = aiConfigurationSources self.recentProjectsStore = recentProjectsStore self.workspaceSessionStore = workspaceSessionStore diff --git a/macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift b/macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift new file mode 100644 index 000000000..0a221eb98 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift @@ -0,0 +1,14 @@ +import LitheModuleAPI + +/// The Agent setup entry is available before its optional module is enabled. +/// Only static metadata is read; module construction remains with the runtime. +enum AgentConversationEntryPolicy { + static func rightSidebarContributions(from enabledContributions: [ModuleContribution]) -> [ModuleContribution] { + var entries = enabledContributions.filter { $0.placement == .rightSidebar } + let existingIDs = Set(entries.map(\.id)) + entries += BuiltInModuleCatalog.contributions(for: .agentConversation).filter { + $0.placement == .rightSidebar && !existingIDs.contains($0.id) + } + return entries.sorted { ($0.order, $0.id) < ($1.order, $1.id) } + } +} diff --git a/macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift b/macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift new file mode 100644 index 000000000..bdf9623e3 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift @@ -0,0 +1,115 @@ +import Foundation +import LitheCoreContracts + +/// State of Settings › Agents: runtime detection and adapter installs. +/// +/// Nothing runs until the page asks for a refresh, so users who never open it +/// pay no detection or process cost. +@MainActor +final class AgentManagementFeatureModel: ObservableObject { + enum Phase: Equatable { + case idle + case checking + case ready + case failed(String) + } + + @Published private(set) var phase: Phase = .idle + @Published private(set) var status: AgentManagementStatus? + /// Agent whose adapter is being installed or removed. + @Published private(set) var busyAgentID: String? + /// Last install or removal failure per agent. + @Published private(set) var errors: [String: String] = [:] + @Published private(set) var cliUpdates: [String: AgentCliUpdateResult] = [:] + + @Published private(set) var installProgress: AgentInstallProgress? + @Published private(set) var isCancelling = false + private(set) var operationStartedAt: Date? + private var operationID: UUID? + + private let service: any AgentManagementService + private let dataDirectory: URL + private var operation: Task? + + init(service: any AgentManagementService, dataDirectory: URL) { + self.service = service + self.dataDirectory = dataDirectory + } + + func refresh() async { + guard phase != .checking else { return } + phase = .checking + do { + status = try await service.status(dataDirectory: dataDirectory) + phase = .ready + } catch { + phase = .failed(error.localizedDescription) + } + } + + func install(_ agentID: String) { + run(agentID) { service, directory, progress in + _ = try await service.install(agentID: agentID, dataDirectory: directory, onProgress: progress) + return nil + } + } + + func uninstall(_ agentID: String) { + run(agentID) { service, directory, _ in + try await service.uninstall(agentID: agentID, dataDirectory: directory) + return nil + } + } + + /// Update through the verified installation owner and retain warning diagnostics. + func installCli(_ agentID: String) { + run(agentID) { service, directory, progress in + try await service.installCli(agentID: agentID, dataDirectory: directory, onProgress: progress) + } + } + + /// Stops a running npm install; the previous install stays intact. + func cancelOperation() { + guard operation != nil else { return } + isCancelling = true + operation?.cancel() + } + + private func run( + _ agentID: String, + _ work: @escaping @Sendable (any AgentManagementService, URL, @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> AgentCliUpdateResult? + ) { + guard busyAgentID == nil else { return } + let id = UUID() + operationID = id + operationStartedAt = Date() + installProgress = nil + isCancelling = false + busyAgentID = agentID + errors[agentID] = nil + cliUpdates[agentID] = nil + let service = service + let directory = dataDirectory + operation = Task { [weak self] in + do { + let result = try await work(service, directory) { [weak self] progress in + Task { @MainActor [weak self] in + guard let self, self.operationID == id, !self.isCancelling else { return } + self.installProgress = progress + } + } + if !Task.isCancelled { self?.cliUpdates[agentID] = result } + } catch is CancellationError { + } catch { + if !Task.isCancelled { self?.errors[agentID] = error.localizedDescription } + } + self?.operationID = nil + self?.installProgress = nil + self?.operationStartedAt = nil + self?.isCancelling = false + self?.busyAgentID = nil + self?.operation = nil + await self?.refresh() + } + } +} diff --git a/macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift b/macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift new file mode 100644 index 000000000..8e63be933 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift @@ -0,0 +1,111 @@ +import Foundation +import LitheCoreContracts + +/// An editor draft has no persistence side effects until the user saves it. +struct AgentProviderDraft: Identifiable { + let id = UUID() + var providerID: UUID? + var source: AIConfigurationSourceKind + var name: String = "" + var configuration: String + var authentication: String + var allowsInsecureHTTP = false +} + +/// Coordinates metadata persistence and secure credentials without altering CLI files. +@MainActor +struct AgentProviderConfiguration { + let settings: AppSettings + let secureStore: any SecureStore + let parser: any AgentProviderConfigurationParsing + + func draft(source: AIConfigurationSourceKind, provider: AIProviderProfile? = nil) throws -> AgentProviderDraft { + let endpoint = provider?.endpoint ?? "" + let model = provider?.model ?? "" + let key = provider.flatMap { secureStore.read(key: $0.apiKeyIdentifier) } ?? "" + let configuration: String + let authentication: String + switch source { + case .codex: + configuration = """ + model = \(try quoted(model)) + model_provider = "custom" + + [model_providers.custom] + name = "custom" + base_url = \(try quoted(endpoint)) + wire_api = "responses" + requires_openai_auth = true + """ + authentication = try json(["OPENAI_API_KEY": key]) + case .claude: + configuration = try json(["env": ["ANTHROPIC_BASE_URL": endpoint, + "ANTHROPIC_MODEL": model, "ANTHROPIC_API_KEY": key]]) + authentication = "" + } + return AgentProviderDraft(providerID: provider?.id, source: source, name: provider?.name ?? "", + configuration: configuration, authentication: authentication, + allowsInsecureHTTP: provider?.allowsInsecureHTTP ?? false) + } + + /// Validate before stopping a connection or changing saved state. + func validate(_ draft: AgentProviderDraft) throws -> (AIProviderProfile, String) { + let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { throw AgentProviderConfigurationError.invalidName } + let snapshot = try parser.parse(source: draft.source, configuration: draft.configuration, authentication: draft.authentication) + let expected: CommitMessageAPIProtocol = draft.source == .codex ? .responses : .anthropicMessages + guard snapshot.apiProtocol == expected else { throw AgentProviderConfigurationError.unsupportedProtocol } + let existing = draft.providerID.flatMap { id in settings.commitMessageAI.providers.first { $0.id == id } } + // Imported profiles stay linked to their CLI; stale editor drafts cannot recreate deleted profiles. + guard draft.providerID == nil || existing?.credentialSource == .local else { + throw AgentProviderConfigurationError.invalidConfiguration + } + let provider = AIProviderProfile(id: existing?.id ?? UUID(), name: name, + endpoint: snapshot.endpoint.trimmingCharacters(in: .whitespacesAndNewlines), model: snapshot.model, + apiProtocol: snapshot.apiProtocol, authentication: snapshot.authentication, + allowsInsecureHTTP: draft.allowsInsecureHTTP, apiKeyIdentifier: existing?.apiKeyIdentifier) + guard provider.isValid, let url = provider.endpointURL, + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil else { + throw AgentProviderConfigurationError.invalidEndpoint + } + guard url.scheme?.lowercased() != "http" || draft.allowsInsecureHTTP else { + throw AgentProviderConfigurationError.insecureHTTP + } + let key = snapshot.apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !key.isEmpty else { throw AgentProviderConfigurationError.missingAPIKey } + return (provider, key) + } + + func save(_ provider: AIProviderProfile, key: String, replacing previous: AIProviderProfile?) throws { + // Another project can change the shared registry while its peer awaits process exit. + // Check immediately before credential mutation, with no suspension before publication. + let current = settings.commitMessageAI.providers.first { $0.id == provider.id } + guard current == previous else { throw AgentProviderConfigurationError.invalidConfiguration } + // A failed credential write must not leave an apparently usable provider in settings. + try secureStore.write(key, key: provider.apiKeyIdentifier) + var value = settings.commitMessageAI + if let index = value.providers.firstIndex(where: { $0.id == provider.id }) { + value.providers[index] = provider + } else { + value.providers.append(provider) + } + value.codexImportCompleted = true + settings.commitMessageAI = value + } + + func remove(_ provider: AIProviderProfile) throws { + guard provider.credentialSource == .local else { throw AgentProviderConfigurationError.invalidConfiguration } + // Failed deletion keeps the profile available for retry instead of orphaning its credential. + try secureStore.delete(key: provider.apiKeyIdentifier) + settings.removeAIProvider(provider.id) + } + + private func quoted(_ value: String) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + return String(decoding: try encoder.encode(value), as: UTF8.self) + } + private func json(_ value: [String: Any]) throws -> String { + String(decoding: try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), as: UTF8.self) + } +} diff --git a/macos/Sources/Lithe/Application/Features/AgentSetupDiagnostics.swift b/macos/Sources/Lithe/Application/Features/AgentSetupDiagnostics.swift new file mode 100644 index 000000000..47d606bc9 --- /dev/null +++ b/macos/Sources/Lithe/Application/Features/AgentSetupDiagnostics.swift @@ -0,0 +1,211 @@ +import Foundation +import LitheCoreContracts + +/// Localized, user-facing reasons an agent cannot be installed or started, +/// derived from the structured status so the panel can show them in the +/// interface language. Rust's `issues` list stays the machine truth for +/// whether an install is allowed. +enum AgentSetupDiagnostics { + enum Issue: Equatable { + case nodeMissing(minimumMajor: Int) + case nodeTooOld(minimumMajor: Int, found: String) + case npmMissing + case cliMissing(name: String, installHint: String) + case cliTooOld(name: String, minimumVersion: String, found: String) + + var message: String { + switch self { + case .nodeMissing(let minimum): + String(format: String(localized: "Node.js %lld or later was not found. Install it, then check again."), minimum) + case .nodeTooOld(let minimum, let found): + String(format: String(localized: "Node.js %lld or later is required; found %@."), minimum, found) + case .npmMissing: + String(localized: "npm was not found. It is normally installed with Node.js.") + case .cliMissing(let name, let hint): + String(format: String(localized: "%@ was not found. Install it (for example `%@`), then check again."), name, hint) + case .cliTooOld(let name, let minimum, let found): + String(format: String(localized: "%@ %@ or later is required; found %@. Update it, then check again."), name, minimum, found) + } + } + } + + static func issues(for agent: AgentCatalogStatus, environment: AgentRuntimeEnvironment) -> [Issue] { + var issues: [Issue] = [] + switch environment.node.map({ majorVersion(of: $0.version) }) { + case nil: + issues.append(.nodeMissing(minimumMajor: agent.minimumNodeMajor)) + case let major? where major < agent.minimumNodeMajor: + issues.append(.nodeTooOld(minimumMajor: agent.minimumNodeMajor, found: environment.node?.version ?? "")) + default: + break + } + if environment.npm == nil { + issues.append(.npmMissing) + } + if let cli = agent.cli { + if let detected = cli.detected { + if !isVersion(detected.version, atLeast: cli.minimumVersion) { + issues.append(.cliTooOld(name: cli.name, minimumVersion: cli.minimumVersion, found: detected.version)) + } + } else { + issues.append(.cliMissing(name: cli.name, installHint: cli.installHint)) + } + } + return issues + } + + /// Leading integer of a version such as `v20.11.0` or `20`. + static func majorVersion(of version: String) -> Int { + let digits = version.drop { !$0.isNumber }.prefix { $0.isNumber } + return Int(digits) ?? 0 + } + + /// Numeric dotted comparison; missing components count as zero, and a + /// pre-release suffix is ignored. + static func isVersion(_ version: String, atLeast minimum: String) -> Bool { + let lhs = components(of: version) + let rhs = components(of: minimum) + for index in 0.. right } + } + return true + } + + private static func components(of version: String) -> [Int] { + version.drop { !$0.isNumber } + .prefix { $0.isNumber || $0 == "." } + .split(separator: ".") + .map { Int($0) ?? 0 } + } +} + +/// One row of the per-agent preflight checklist shown in the Agent panel +/// settings, mirroring the structured status the host reports. +struct AgentPreflightCheck: Identifiable, Equatable { + enum Status: Int, Comparable { + case pass + case warn + case fail + + static func < (lhs: Status, rhs: Status) -> Bool { lhs.rawValue < rhs.rawValue } + } + + /// What the user can do about a failing check, if anything. + enum Fix: Equatable { + case install + case update + case installCli + case updateCli + case fetchLocalConfiguration + } + + let id: String + let title: String + let status: Status + let message: String + var fix: Fix? +} + +extension AgentSetupDiagnostics { + /// Preflight rows for `agent`: runtime, the user's CLI, the adapter + /// install, and the provider assignment. Order is fixed so the list does + /// not jump between refreshes. + static func preflight( + for agent: AgentCatalogStatus, + environment: AgentRuntimeEnvironment, + hasProvider: Bool + ) -> [AgentPreflightCheck] { + var checks: [AgentPreflightCheck] = [] + + let nodeTitle = String(format: String(localized: "Node.js %lld or later"), agent.minimumNodeMajor) + switch environment.node { + case nil: + checks.append(.init(id: "node", title: nodeTitle, status: .fail, + message: Issue.nodeMissing(minimumMajor: agent.minimumNodeMajor).message)) + case let node? where majorVersion(of: node.version) < agent.minimumNodeMajor: + checks.append(.init(id: "node", title: nodeTitle, status: .fail, + message: Issue.nodeTooOld(minimumMajor: agent.minimumNodeMajor, found: node.version).message)) + case let node?: + checks.append(.init(id: "node", title: nodeTitle, status: .pass, message: "\(node.version) · \(node.path)")) + } + + if let npm = environment.npm { + checks.append(.init(id: "npm", title: "npm", status: .pass, message: "\(npm.version) · \(npm.path)")) + } else { + checks.append(.init(id: "npm", title: "npm", status: .fail, message: Issue.npmMissing.message)) + } + + if let cli = agent.cli { + let title = String(format: String(localized: "%@ %@ or later"), cli.name, cli.minimumVersion) + let canUpdate = cli.installation?.canUpdate ?? true + if let detected = cli.detected { + if isVersion(detected.version, atLeast: cli.minimumVersion) { + checks.append(.init(id: "cli", title: title, status: .pass, message: "\(detected.version) · \(detected.path)")) + } else { + checks.append(.init(id: "cli", title: title, status: .warn, + message: Issue.cliTooOld(name: cli.name, minimumVersion: cli.minimumVersion, found: detected.version).message, + fix: canUpdate ? .updateCli : nil)) + } + } else { + checks.append(.init(id: "cli", title: title, status: .fail, + message: Issue.cliMissing(name: cli.name, installHint: cli.installHint).message, + fix: canUpdate ? .installCli : nil)) + } + if let installation = cli.installation, let index = checks.firstIndex(where: { $0.id == "cli" }) { + let check = checks[index] + let source: String = switch installation.source { + case .npm: "npm" + case .homebrew: "Homebrew" + case .native: String(localized: "Native installer") + case .missing: String(localized: "Not installed") + case .unknown: String(localized: "Unknown source") + } + let provenance = String(format: String(localized: "Installation: %@"), source) + let hint = NSLocalizedString(installation.updateHint, comment: "CLI update guidance") + checks[index] = .init(id: check.id, title: check.title, status: check.status, + message: "\(check.message)\n\(provenance)\n\(hint)", fix: check.fix) + } + } + + // The adapter only needs Node.js and npm; the user's CLI is checked + // separately and does not block installing the adapter. + let canInstallAdapter = !checks.contains { ($0.id == "node" || $0.id == "npm") && $0.status == .fail } + let adapterTitle = String(localized: "ACP adapter") + switch agent.installedVersion { + case nil: + checks.append(.init(id: "adapter", title: adapterTitle, status: .fail, + message: String(format: String(localized: "%@ is not installed. Lithe installs it with your npm into its application data."), "\(agent.package)@\(agent.version)"), + fix: canInstallAdapter ? .install : nil)) + case let installed? where installed != agent.version: + checks.append(.init(id: "adapter", title: adapterTitle, status: .warn, + message: String(format: String(localized: "Installed %@ · update to %@ available"), installed, agent.version), + fix: canInstallAdapter ? .update : nil)) + case let installed?: + checks.append(.init(id: "adapter", title: adapterTitle, status: .pass, + message: String(format: String(localized: "Installed %@"), installed))) + } + + checks.append(.init( + id: "provider", + title: String(localized: "Agent provider"), + status: hasProvider ? .pass : .fail, + message: hasProvider + ? String(localized: "Signed in through the ACP gateway with the API key from your local configuration.") + : String(localized: "Choose a local or custom provider so the Agent can sign in."), + fix: hasProvider ? nil : .fetchLocalConfiguration + )) + return checks + } + + /// Whether the adapter may be installed: only Node.js and npm gate it. + static func canInstallAdapter(_ checks: [AgentPreflightCheck]) -> Bool { + !checks.contains { ($0.id == "node" || $0.id == "npm") && $0.status == .fail } + } + + /// Worst status across the checklist; `nil` when there are no checks. + static func summary(of checks: [AgentPreflightCheck]) -> AgentPreflightCheck.Status? { + checks.map(\.status).max() + } +} diff --git a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift index 85ccdc1a1..536258a74 100644 --- a/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/DocumentFeatureModel.swift @@ -1144,6 +1144,7 @@ final class DocumentFeatureModel: ObservableObject { func processExternalChanges(_ urls: [URL]) -> Bool { let changedPaths = Set(urls.map { $0.standardizedFileURL.path }) for document in observedDocuments where changedPaths.contains(document.url.standardizedFileURL.path) { + document.invalidateIconContent() if let metadata = fileStorage.metadata(for: document.url) { document.updateFileSystemWritable(metadata.isWritable) } diff --git a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift index 59e3a2dc6..fb6dd9127 100644 --- a/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift +++ b/macos/Sources/Lithe/Application/Features/WorkbenchFeatureModel.swift @@ -1,7 +1,8 @@ import Combine import Foundation -/// Owns the independent Maven dock and the mutually exclusive bottom tool window. +/// Owns the right dock and the bottom tool window. Each area shows at most one +/// tool window, and the two areas open and close independently of each other. /// /// AppModel keeps compatibility accessors for existing callers while new /// workbench code can depend on this focused state model directly. @@ -10,6 +11,7 @@ final class WorkbenchFeatureModel: ObservableObject { enum ToolWindow: Equatable { case gitLog case terminal + case agent case references case problems case maven @@ -34,7 +36,7 @@ final class WorkbenchFeatureModel: ObservableObject { @Published private(set) var settingsCategoryRequest = 0 @Published var isCloneRepositoryPresented = false @Published private(set) var activeToolWindow: ToolWindow? - @Published private(set) var isMavenDockVisible = false + @Published private(set) var activeRightToolWindow: ToolWindow? private let layoutStore: WorkbenchLayoutStore? private var sidebarSelectionHandler: ((SidebarDestination) -> Void)? @@ -49,14 +51,30 @@ final class WorkbenchFeatureModel: ObservableObject { sidebarSelectionHandler = handler } + /// Tool windows docked beside the editor instead of below it. Maven's + /// navigation tree and the Agent conversation both need the editor to stay + /// visible while they are open. + static func isRightDocked(_ toolWindow: ToolWindow) -> Bool { + switch toolWindow { + case .maven, .agent: true + default: false + } + } + func isVisible(_ toolWindow: ToolWindow) -> Bool { - toolWindow == .maven ? isMavenDockVisible : activeToolWindow == toolWindow + Self.isRightDocked(toolWindow) + ? activeRightToolWindow == toolWindow + : activeToolWindow == toolWindow } func setVisibility(_ toolWindow: ToolWindow, isVisible: Bool) { - if toolWindow == .maven { - guard isMavenDockVisible != isVisible else { return } - isMavenDockVisible = isVisible + if Self.isRightDocked(toolWindow) { + if isVisible { + guard activeRightToolWindow != toolWindow else { return } + activeRightToolWindow = toolWindow + } else if activeRightToolWindow == toolWindow { + activeRightToolWindow = nil + } return } if isVisible { @@ -77,7 +95,7 @@ final class WorkbenchFeatureModel: ObservableObject { func hideAllToolWindows() { hideBottomToolWindow() - isMavenDockVisible = false + activeRightToolWindow = nil } func presentSettings(category: SettingsCategory) { diff --git a/macos/Sources/Lithe/Core/Ports/AgentProviderConfigurationParsing.swift b/macos/Sources/Lithe/Core/Ports/AgentProviderConfigurationParsing.swift new file mode 100644 index 000000000..755650024 --- /dev/null +++ b/macos/Sources/Lithe/Core/Ports/AgentProviderConfigurationParsing.swift @@ -0,0 +1,32 @@ +import Foundation +import LitheCoreContracts + +/// Parses an explicit editor draft. No filesystem discovery or environment fallback. +protocol AgentProviderConfigurationParsing: Sendable { + func parse(source: AIConfigurationSourceKind, configuration: String, authentication: String) throws -> AIConfigurationSnapshot +} + +enum AgentProviderConfigurationError: LocalizedError, Equatable { + case invalidConfiguration, invalidConfigurationQuotes, invalidAuthentication, missingAPIKey, invalidName, invalidEndpoint, insecureHTTP, unsupportedProtocol, busy, unavailable + + var errorDescription: String? { + switch self { + case .invalidConfiguration: String(localized: "Check the configuration format, API URL and model. Each configuration must be under 64 KiB.") + case .invalidConfigurationQuotes: String(localized: "Invalid TOML. Use straight quotes (\") instead of smart quotes (“ ”).") + case .invalidAuthentication: String(localized: "Authentication must be a JSON object containing an OPENAI_API_KEY string.") + case .missingAPIKey: String(localized: "Enter an API key. CLI login credentials cannot be used as an API key.") + case .invalidName: String(localized: "Enter a provider name.") + case .invalidEndpoint: String(localized: "Enter an HTTP or HTTPS API URL without credentials, query parameters or fragments.") + case .insecureHTTP: String(localized: "HTTP is blocked until you explicitly allow it for this provider.") + case .unsupportedProtocol: String(localized: "Codex Agents require the Responses API. Claude Agents require an Anthropic API key.") + case .busy: String(localized: "Wait for the Agent operation to finish before changing providers.") + case .unavailable: String(localized: "Provider configuration parsing is unavailable.") + } + } +} + +struct UnavailableAgentProviderConfigurationParser: AgentProviderConfigurationParsing { + func parse(source: AIConfigurationSourceKind, configuration: String, authentication: String) throws -> AIConfigurationSnapshot { + throw AgentProviderConfigurationError.unavailable + } +} diff --git a/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift new file mode 100644 index 000000000..f4ec18b90 --- /dev/null +++ b/macos/Sources/Lithe/Core/Rust/RustAgentManagementService.swift @@ -0,0 +1,96 @@ +import Foundation +import LitheCoreContracts +import LitheRustCore + +/// Runs `agent.*` Core commands off the main thread; cancelling the calling +/// task cancels the Core operation, which stops a running npm install. +struct RustAgentManagementService: AgentManagementService { + let core: RustCoreBridge + + private struct StatusPayload: Encodable { let dataDirectory: String } + private struct AgentPayload: Encodable { let dataDirectory: String; let agentId: String } + private struct InstallResult: Decodable { let installedVersion: String } + private struct UninstallResult: Decodable { let agentId: String } + private struct InstallEvent: Decodable { + let kind: String + let operationId: String + let progress: AgentInstallProgress + } + + func status(dataDirectory: URL) async throws -> AgentManagementStatus { + try await run("agent.status", StatusPayload(dataDirectory: dataDirectory.path)) + } + + func install(agentID: String, dataDirectory: URL) async throws -> String { + try await install(agentID: agentID, dataDirectory: dataDirectory, onProgress: { _ in }) + } + + func install(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String { + let result: InstallResult = try await run( + "agent.install", AgentPayload(dataDirectory: dataDirectory.path, agentId: agentID), onProgress: onProgress + ) + return result.installedVersion + } + + func uninstall(agentID: String, dataDirectory: URL) async throws { + let _: UninstallResult = try await run( + "agent.uninstall", AgentPayload(dataDirectory: dataDirectory.path, agentId: agentID) + ) + } + + func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult { + try await installCli(agentID: agentID, dataDirectory: dataDirectory, onProgress: { _ in }) + } + + func installCli(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> AgentCliUpdateResult { + try await run( + "agent.installCli", AgentPayload(dataDirectory: dataDirectory.path, agentId: agentID), onProgress: onProgress + ) + } + + private func run( + _ command: String, _ payload: Payload, + onProgress: (@Sendable (AgentInstallProgress) -> Void)? = nil + ) async throws -> Result { + let operationID = UUID().uuidString + let core = core + return try await withTaskCancellationHandler { + try await Task.detached(priority: .userInitiated) { + try core.executeResult(command: command, payload: payload, operationID: operationID, onEvent: onProgress.map { callback in + { event in + guard let data = event.data(using: .utf8), + let decoded = try? JSONDecoder().decode(InstallEvent.self, from: data), + decoded.kind == "agentInstallProgress", decoded.operationId == operationID else { return } + callback(decoded.progress) + } + }).get() + }.value + } onCancel: { + core.cancel(operationID: operationID) + } + } +} + +/// The existing event ABI is synchronous: the owned callback remains alive until +/// the call returns, including cancellation and failure paths. +private final class AgentInstallEventCallback { + let receive: @Sendable (String) -> Void + init(_ receive: @escaping @Sendable (String) -> Void) { self.receive = receive } +} + +extension RustCoreBridge { + func executeAgentObserving(_ request: String, receive: @escaping @Sendable (String) -> Void) -> UnsafeMutablePointer? { + let callback = AgentInstallEventCallback(receive) + return withExtendedLifetime(callback) { + let context = Unmanaged.passUnretained(callback).toOpaque() + return request.withCString { lithe_bridge_execute_json_with_events($0, agentInstallEventCallback, context) } + } + } +} + +private func agentInstallEventCallback(_ event: UnsafePointer?, _ context: UnsafeMutableRawPointer?) { + guard let event, let context else { return } + Unmanaged.fromOpaque(context).takeUnretainedValue().receive(String(cString: event)) +} diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitExecution.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitExecution.swift index c4eb19737..98d45474d 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitExecution.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge+GitExecution.swift @@ -34,6 +34,14 @@ private final class GitExecutionReceiver { } extension RustCoreBridge { + func executeObserving( + _ request: String, onEvent: (@Sendable (String) -> Void)?, + context: GitExecutionContext?, journal: GitExecutionJournal? + ) -> UnsafeMutablePointer? { + if let onEvent { return executeAgentObserving(request, receive: onEvent) } + return executeGitObserving(request, context: context, journal: journal) + } + func executeGitObserving( _ request: String, context: GitExecutionContext?, journal: GitExecutionJournal? = nil ) -> UnsafeMutablePointer? { diff --git a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift index 99c2d6eb2..d2c19e924 100644 --- a/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift +++ b/macos/Sources/Lithe/Core/Rust/RustCoreBridge.swift @@ -3815,12 +3815,14 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { func executeResult( command: String, payload: Payload, - operationID: String? = nil + operationID: String? = nil, + onEvent: (@Sendable (String) -> Void)? = nil ) -> Result { let outcome: Result, CoreCallError> = decodeEnvelope( command: command, payload: payload, - operationID: operationID + operationID: operationID, + onEvent: onEvent ) return outcome.flatMap { envelope in guard let value = envelope.data else { @@ -3848,7 +3850,8 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { private func decodeEnvelope( command: String, payload: Payload, - operationID: String? = nil + operationID: String? = nil, + onEvent: (@Sendable (String) -> Void)? = nil ) -> Result, CoreCallError> { guard isAvailable else { return .failure(CoreCallError( @@ -3877,7 +3880,7 @@ struct RustCoreBridge: Sendable, IncrementalLanguageServerRuntimeCore { ) ), let request = String(data: requestData, encoding: .utf8), - let responsePointer = executeGitObserving(request, context: execution, + let responsePointer = executeObserving(request, onEvent: onEvent, context: execution, journal: observesGit && execution == nil ? gitExecutionJournal : nil) else { return .failure(CoreCallError( code: "unknown", diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index 2312645b9..078f484c9 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -848,7 +848,7 @@ private struct SettingsWindowAccessor: NSViewRepresentable { enum SettingsWindowChrome { static func configure(_ window: NSWindow, title: String, themePreference: AppThemePreference) { window.title = title - window.level = .floating + window.level = .normal let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { window.appearance = windowAppearance diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift index 0a0a7d3ef..e4109ad00 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AIConfiguration.swift @@ -44,13 +44,11 @@ extension AppModel { } var activeCommitMessageAPIKey: String { - guard let provider = settings.activeCommitMessageProvider else { return "" } - return services.credentialResolver.readAPIKey(for: provider) ?? "" + settings.activeCommitMessageProvider.map(apiKey(for:)) ?? "" } var activeCommitMessageCredentialIsConfigurationManaged: Bool { - guard let provider = settings.activeCommitMessageProvider else { return false } - return configurationSource(for: provider) != nil + settings.activeCommitMessageProvider.map(credentialIsConfigurationManaged(_:)) ?? false } var activeCommitMessageConfigurationSourceTitle: String? { @@ -58,7 +56,7 @@ extension AppModel { } var activeCommitMessageConfigurationSourceDescription: String? { - settings.activeCommitMessageProvider.flatMap(configurationSource(for:))?.settingsDescription + settings.activeCommitMessageProvider.flatMap(configurationSourceDescription(for:)) } var activeCommitMessageCredentialIsCodexManaged: Bool { @@ -67,8 +65,25 @@ extension AppModel { func saveActiveCommitMessageAPIKey(_ value: String) { guard let provider = settings.activeCommitMessageProvider else { return } - if activeCommitMessageCredentialIsConfigurationManaged { - showNotification("API key is managed by \(activeCommitMessageConfigurationSourceTitle ?? "AI") configuration") + saveAPIKey(value, for: provider) + } + + func apiKey(for provider: AIProviderProfile) -> String { + services.credentialResolver.readAPIKey(for: provider) ?? "" + } + + /// Imported providers read their key from the Codex or Claude configuration. + func credentialIsConfigurationManaged(_ provider: AIProviderProfile) -> Bool { + configurationSource(for: provider) != nil + } + + func configurationSourceDescription(for provider: AIProviderProfile) -> String? { + configurationSource(for: provider)?.settingsDescription + } + + func saveAPIKey(_ value: String, for provider: AIProviderProfile) { + if let source = configurationSource(for: provider) { + showNotification("API key is managed by \(source.title) configuration") return } do { diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift new file mode 100644 index 000000000..1995b9f3b --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentConversation.swift @@ -0,0 +1,200 @@ +import Foundation +import LitheAgentConversationModule +import LitheCoreContracts +import LitheModuleAPI + +extension AppModel { + func copyAgentSessionID(_ id: String) { + platformUI.copyToClipboard(id) + } + + func openAgentFile(_ location: AgentToolDetails.Location) { + guard let workspaceURL, let url = location.fileURL(in: workspaceURL) else { + showNotification(String(localized: "This file is outside the current project.")) + return + } + if let line = location.line { + navigateToEditorLocation(url: url, line: line - 1, utf16Column: 0) + } else { + openFile(url) + } + } + + var isAgentConversationEnabled: Bool { + guard let snapshot = try? services.moduleRuntime.snapshot(for: .agentConversation) else { return false } + return snapshot.state != .disabled + } + + var agentConversationFeatureIfActive: AgentConversationFeatureModel? { + (services.moduleRuntime.capability(.agentConversation) as? AgentConversationCapability)?.feature + } + + /// The panel stays open when the feature is turned off; only the Agent + /// processes stop and the panel explains how to turn it back on. + func setAgentConversationEnabled(_ enabled: Bool) async { + if !enabled { + agentConversationNeedsAttention = false + } + do { + try await services.moduleRuntime.setEnabled(enabled, for: .agentConversation) + objectWillChange.send() + } catch { + showNotification(error.localizedDescription) + return + } + if enabled, workbenchFeature.isVisible(.agent) { + activateAgentConversation() + } + } + + /// Show or hide the panel. The panel always renders its full layout; the + /// module is only activated when the feature is enabled. + func toggleAgentConversation() { + guard workspaceURL != nil else { return } + guard toggleToolWindow(.agent) else { return } + activateAgentConversation() + } + + func activateAgentConversation() { + guard isAgentConversationEnabled, agentConversationFeatureIfActive == nil else { + connectAgentConversation() + return + } + Task { @MainActor [weak self] in + guard let self else { return } + do { + await awaitModuleRuntimeShutdown() + _ = try await services.moduleRuntime.activateCapability(.agentConversation) + objectWillChange.send() + connectAgentConversation() + } catch { + showNotification(error.localizedDescription) + } + } + } + + /// Why a message cannot be sent right now, before any Agent is involved. + var agentConversationSetupError: AgentConversationError? { + if !isAgentConversationEnabled { return .featureDisabled } + if configuredAgentOptions.isEmpty { return .noAgentConfigured } + if agentConversationFeatureIfActive == nil { return .moduleStarting } + return nil + } + + /// Which local CLI configuration an agent follows, if any. + static func localConfigurationSource(for agentID: String) -> AIConfigurationSourceKind? { + switch agentID { + case "codex-acp": .codex + case "claude-acp": .claude + default: nil + } + } + + /// Read the user's own CLI configuration (endpoint, model, API key) and + /// bind it to `agentID`. Lithe keeps following that file; nothing is + /// copied into Lithe settings except the provider profile. The commit + /// message provider is left untouched. + @discardableResult + func importLocalConfiguration(for agentID: String, source: AIConfigurationSourceKind, name: String) -> Bool { + guard let configuration = loadAIConfigurations().first(where: { $0.source == source }) else { + detectedAIConfigurations.removeAll { $0.source == source } + showNotification(String(format: String(localized: "No %@ configuration was found on this Mac."), source.title)) + return false + } + let commitProviderID = settings.commitMessageAI.activeProviderID + let provider = settings.importAIConfiguration(configuration) + settings.commitMessageAI.activeProviderID = commitProviderID + try? services.secureStore.delete(key: provider.apiKeyIdentifier) + detectedAIConfigurations.removeAll { $0.source == source } + detectedAIConfigurations.append(configuration) + settings.setAgentProvider(provider.id, for: agentID, name: name) + showNotification(String(format: String(localized: "%@ now follows your local %@ configuration."), name, source.title)) + return true + } + + /// Directory holding Lithe-managed ACP adapter installs. + var agentDataDirectory: URL { + services.fileStorage.applicationSupportDirectory() + .appendingPathComponent("Lithe", isDirectory: true) + } + + /// Agents with a provider assigned in Settings › Agents, for the panel. + var configuredAgentOptions: [AgentOption] { + settings.agentConfigurations + .filter { id, configuration in + configuration.providerID != nil + && (id != AgentConfiguration.customAgentID + || !settings.agentCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + .map { id, configuration -> AgentOption in + let provider = settings.agentProvider(for: id) + let model = provider?.model.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return AgentOption(id: id, name: configuration.name, modelName: model.isEmpty ? provider?.name : model) + } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + /// Refresh the panel's agents and start the selected one if needed. + func connectAgentConversation() { + guard !isChangingAgentProvider else { return } + guard let feature = agentConversationFeatureIfActive else { return } + if let workspaceURL { feature.bindWorkspace(workspaceURL) } + feature.onAttentionChanged = { [weak self] needsAttention in + self?.agentConversationNeedsAttention = needsAttention + } + feature.setAgents(configuredAgentOptions) + guard let agentID = feature.selectedAgentID else { return } + let connection = feature.connection(for: agentID) + guard !connection.hasActiveConnection else { return } + do { + // Refresh the displayed fallback even when credential validation fails. + defer { feature.setAgents(configuredAgentOptions) } + let configuration = try agentLaunchConfiguration(agentID: agentID) + try connection.connect(configuration: configuration) + } catch { + // The panel shows the reason and offers a retry. + connection.reportConnectionFailure(error.localizedDescription) + } + } + + func selectAgentConversationAgent(_ agentID: String) { + agentConversationFeatureIfActive?.selectAgent(agentID) + connectAgentConversation() + } + + func agentLaunchConfiguration(agentID: String) throws -> AgentLaunchConfiguration { + guard let workspaceURL else { throw AgentConversationError.notConnected } + // A saved import is not the current CLI default. Read through the same + // configuration ports used for credentials, once at connection startup. + settings.refreshAgentModels(from: services.aiConfigurationSources.compactMap { $0.loadModel() }) + guard let provider = settings.agentProvider(for: agentID) else { + throw AgentConversationError.missingProvider + } + let apiKey = services.credentialResolver.readAPIKey(for: provider)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !apiKey.isEmpty else { throw AgentConversationError.missingAPIKey } + let isCustom = agentID == AgentConfiguration.customAgentID + let command = settings.agentCommand.trimmingCharacters(in: .whitespacesAndNewlines) + if isCustom && command.isEmpty { throw AgentConversationError.missingCommand } + // One argument per line, passed to the process without shell parsing. + let arguments = isCustom + ? settings.agentArguments + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + : [] + return AgentLaunchConfiguration( + agentID: isCustom ? nil : agentID, + command: command, + arguments: arguments, + workspaceURL: workspaceURL, + dataDirectory: agentDataDirectory, + providerProtocol: provider.apiProtocol.rawValue, + providerEndpoint: provider.endpoint, + apiKey: apiKey, + providerName: provider.name, + model: provider.model, + allowsInsecureHTTP: provider.allowsInsecureHTTP + ) + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift new file mode 100644 index 000000000..d7ff40a2c --- /dev/null +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift @@ -0,0 +1,91 @@ +import Foundation +import LitheCoreContracts +import LitheAgentConversationModule + +extension AppModel { + private var agentProviderConfiguration: AgentProviderConfiguration { + AgentProviderConfiguration(settings: settings, secureStore: services.secureStore, + parser: services.agentProviderConfigurationParser) + } + + func agentProviderDraft(source: AIConfigurationSourceKind, provider: AIProviderProfile? = nil) throws -> AgentProviderDraft { + try agentProviderConfiguration.draft(source: source, provider: provider) + } + + func saveAgentProvider(_ draft: AgentProviderDraft) async throws { + try beginAgentProviderChange() + defer { finishAgentProviderChange() } + let (provider, key) = try agentProviderConfiguration.validate(draft) + let previous = settings.commitMessageAI.providers.first { $0.id == provider.id } + let affected = settings.agentConfigurations.filter { $0.value.providerID == provider.id }.map(\.key) + try await stopAgentsForProviderChange(affected) + try agentProviderConfiguration.save(provider, key: key, replacing: previous) + if !affected.isEmpty { connectAgentConversation() } + } + + func deleteAgentProvider(_ provider: AIProviderProfile) async throws { + try beginAgentProviderChange() + defer { finishAgentProviderChange() } + let affected = settings.agentConfigurations.filter { $0.value.providerID == provider.id }.map(\.key) + try await stopAgentsForProviderChange(affected) + try agentProviderConfiguration.remove(provider) + agentConversationFeatureIfActive?.setAgents(configuredAgentOptions) + connectAgentConversation() + } + + func useAgentProvider(_ provider: AIProviderProfile?, agentID: String, name: String) async throws { + try beginAgentProviderChange() + defer { finishAgentProviderChange() } + if let provider { + guard settings.commitMessageAI.providers.contains(where: { $0.id == provider.id }), provider.isValid else { + throw AgentProviderConfigurationError.invalidConfiguration + } + } + try await stopAgentsForProviderChange([agentID]) + settings.setAgentProvider(provider?.id, for: agentID, name: name) + agentConversationFeatureIfActive?.setAgents(configuredAgentOptions) + if provider != nil { agentConversationFeatureIfActive?.selectAgent(agentID) } + connectAgentConversation() + } + + func useLocalAgentProvider(source: AIConfigurationSourceKind, agentID: String, name: String) async throws { + try beginAgentProviderChange() + defer { finishAgentProviderChange() } + // Discover before stopping: a missing local configuration must preserve the current connection. + guard loadAIConfigurations().contains(where: { $0.source == source }) else { + throw AgentConversationError.missingProvider + } + try await stopAgentsForProviderChange([agentID]) + if importLocalConfiguration(for: agentID, source: source, name: name) { + agentConversationFeatureIfActive?.setAgents(configuredAgentOptions) + agentConversationFeatureIfActive?.selectAgent(agentID) + connectAgentConversation() + } + } + + private func stopAgentsForProviderChange(_ agentIDs: [String]) async throws { + try Task.checkCancellation() + guard let feature = agentConversationFeatureIfActive else { return } + let connections = agentIDs.sorted().map { feature.connection(for: $0) } + guard connections.allSatisfy({ connection in + !connection.isCreatingSession && connection.connectionState != .connecting && + !connection.conversations.values.contains { + $0.isResponding || $0.isLoading || $0.pendingConfigToken != nil || $0.permission != nil + } + }) else { throw AgentProviderConfigurationError.busy } + for connection in connections { await connection.stop() } + try Task.checkCancellation() + } + + private func beginAgentProviderChange() throws { + try Task.checkCancellation() + guard !isChangingAgentProvider else { throw AgentProviderConfigurationError.busy } + isChangingAgentProvider = true + } + + private func finishAgentProviderChange() { + isChangingAgentProvider = false + // Restore the old profile after a storage failure, or connect using the saved selection. + connectAgentConversation() + } +} diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift index ca0a9cfc8..8763b0d8e 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+RunConfiguration.swift @@ -3,12 +3,7 @@ import LitheCoreContracts import LitheExecutionModule import LitheModuleAPI -/// Run, Maven, and Spring tool window entry points. -/// -/// The workspace readiness gate lives here because Run, Debug, and Test all -/// enter through it: an action that arrives before the workspace snapshot has -/// been applied is deferred and resumed by the snapshot callback rather than -/// launched against a provisional inventory. +/// Run configuration entry points and workspace readiness gates. @MainActor extension AppModel { func toggleSpringEndpoints() { @@ -93,7 +88,6 @@ extension AppModel { ) } - /// 打开源码文件并定位到指定行/列(供构建输出、运行堆栈等可点击文本跳转)。 func openSourceLocation(url: URL, line: Int, column: Int?) { guard workspaceFeature.fileExists(at: url) else { return } navigateToEditorLocation( @@ -120,14 +114,7 @@ extension AppModel { runFeatureIfActive?.select(configuration) } - /// The single entry point for identification. - /// - /// Routing it through here is what keeps the run service from scanning a - /// superseded snapshot: the service can only compare its own state, so the - /// caller has to bring it up to the current snapshot first. When that fails, - /// generation must stop — the service may still hold an older `.ready` - /// inventory, and scanning it would overwrite `generated.json` with stale - /// entry points. + /// Generate configurations only after the current workspace snapshot is ready. func generateRunConfigurations() async { guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } @@ -137,8 +124,6 @@ extension AppModel { case .ready: await generateFromJavaEntrypoints(runFeature, for: identity) case .waitingForSnapshot: - // Report the pending workspace through the generation state when the - // snapshot has not arrived, which the run panel surfaces as a notice. runFeature.reportGenerationProjectNotReady() case .stale: return @@ -156,10 +141,7 @@ extension AppModel { Task { [weak self] in await self?.runSelectedConfigurationAfterActivation() } } - /// Loads project services for the scan currently applied to the workspace. - /// - /// Callers that only want "whatever the workspace has now" use this so the - /// file list and its identity are captured in a single read. + /// Loads services for the workspace snapshot currently applied by the model. func loadProjectServicesForAppliedSnapshot(at workspaceURL: URL) async { let applied = workspaceFeature.appliedSnapshot await loadProjectServices( @@ -169,18 +151,7 @@ extension AppModel { ) } - /// Loads build-system and run state at the workspace boundary. The generic - /// run lifecycle is intentionally not owned by JavaFeatureModel. - /// - /// Spring indexing is scheduled rather than awaited. It scales with the - /// number of Java sources, and run configurations, test discovery, and the - /// Git refresh that follows this call must not wait for it. - /// - /// `files` and `snapshotID` must describe the same scan; the caller captures - /// them together. `resumesDeferredRunAction` is set only by the workspace - /// snapshot callback, because a deferred Run waits for a snapshot and - /// resuming from any other load would either re-enter through - /// `ensureRunProjectReady` or fire the action from an unrelated reload. + /// Loads build-system and run state for one scan; `files` and `snapshotID` must match. func loadProjectServices( at workspaceURL: URL, files: [URL], @@ -188,8 +159,6 @@ extension AppModel { resumesDeferredRunAction: Bool = false ) async { let target = workspaceURL.standardizedFileURL - // The caller established that this load belongs to the current opening, - // so the identity is captured here and re-checked after every await. guard let identity = currentWorkspaceIdentity, identity.url == target else { return } prepareJavaLanguageServerForWorkspaceIfNeeded( at: target, @@ -210,14 +179,8 @@ extension AppModel { }) ) guard let execution = await activateExecutionModule() else { return } - // Module activation suspends; a project switch or a reopen must not let - // this load write the captured inventory into the new opening's run - // service. guard isCurrentWorkspace(identity) else { return } execution.tests.discover(workspaceURL: target, files: files) - // `files` and `snapshotID` are captured together by the caller. Reading - // the applied snapshot here instead would pair this file list with a - // newer scan's identity, which the readiness comparison cannot detect. await execution.projectDevelopment.loadProject( at: target, files: files, @@ -234,7 +197,6 @@ extension AppModel { ) } - /// The opening an entry task captures before its first await. var currentWorkspaceIdentity: WorkspaceIdentity? { guard let url = workspaceURL?.standardizedFileURL else { return nil } return WorkspaceIdentity(url: url, generation: workspaceFeature.workspaceGeneration) @@ -244,20 +206,7 @@ extension AppModel { currentWorkspaceIdentity == identity } - /// Brings the run feature up to the snapshot for a captured opening. - /// - /// Entry tasks capture the opening before any await so a project switch — or - /// a close and reopen of the same path — can be reported as `.stale` instead - /// of being re-deferred against whatever is current when the load finishes. - /// - /// When a newer snapshot is published but the run service still holds an - /// older `.ready` inventory for this workspace, the snapshot callback owns - /// the transition. Loading from the entry path would race that callback and - /// let Restart proceed from a half-applied refresh. - /// - /// When the run service is not already ready for this workspace, the entry - /// path applies the published scan itself (open-before-run, prune, tool - /// window) so readiness does not wait on a callback that may never arrive. + /// Applies the captured workspace snapshot before a run action proceeds. func ensureRunProjectReady( _ runFeature: RunFeatureModel, for identity: WorkspaceIdentity @@ -291,8 +240,6 @@ extension AppModel { case .ready: clearPendingRunAction(for: identity) case .waitingForSnapshot(let waitingIdentity): - // Launching from a provisional inventory resolves toolchains without - // the Maven project, so wait for the snapshot instead of running. deferRunAction(.run, for: waitingIdentity) return case .stale: @@ -452,7 +399,7 @@ extension AppModel { } } - /// Completes the entry workflow, including rejecting actions from an earlier workspace opening. + /// Completes a run action for the current workspace opening. func performStartRunConfiguration(_ configuration: RunConfiguration) async { guard let identity = currentWorkspaceIdentity else { return } guard let runFeature = await activateExecutionModule()?.runFeature else { return } @@ -461,11 +408,6 @@ extension AppModel { case .ready: clearPendingRunAction(for: identity) case .waitingForSnapshot(let waitingIdentity): - // Direct play buttons reach here without going through - // `runSelectedConfiguration`, so they need the same readiness - // gate and must remember which configuration to resume — bound - // to the opening this task started for, not whatever is current - // after an await. deferRunAction(.startConfiguration(configuration), for: waitingIdentity) return case .stale: @@ -525,8 +467,6 @@ extension AppModel { } } - /// A batch is one deferred intent. Launches use the current inventory and - /// retain existing sessions, including output from already running services. func performStartRunConfigurations(_ configurationIDs: [String]) async { let ids = Set(configurationIDs).sorted() guard !ids.isEmpty, let identity = currentWorkspaceIdentity else { return } @@ -648,10 +588,7 @@ extension AppModel { } } -/// Run entry points the workflow coordinator resumes a deferred action into. -/// -/// Conforming here rather than passing closures keeps the resume path a single -/// call on a connected object; the coordinator holds it weakly. +/// Actions resumed by the run workflow coordinator. extension AppModel: RunWorkflowActions { func loadProject(at workspaceURL: URL, files: [URL], snapshotID: UUID?) async { await loadProjectServices(at: workspaceURL, files: files, snapshotID: snapshotID) diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 4e57f7532..92ffdd9a8 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -14,6 +14,7 @@ import LitheCoreContracts @MainActor final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { let id = UUID() + @Published var isChangingAgentProvider = false var workspaceURL: URL? { workspaceSessionCoordinator.workspaceURL } var standaloneFileURL: URL? { workspaceSessionCoordinator.standaloneFileURL } var searchSessionFeature: SearchSessionFeatureModel { featureGraph.searchSession } @@ -78,6 +79,13 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { workspaceFeature.isPerformingProjectItemOperation } @Published var detectedAIConfigurations: [AIConfigurationSnapshot] = [] + /// An Agent conversation in this project waits for a permission decision. + @Published var agentConversationNeedsAttention = false + /// Agent panel settings state, created when the settings are first shown. + lazy var agentManagementFeature = AgentManagementFeatureModel( + service: services.agentManagement, + dataDirectory: agentDataDirectory + ) var commitDraftFeature: CommitDraftFeatureModel { featureGraph.commitDraft } lazy var commitWorkflow = CommitWorkflowComposition.make(model: self) var commitMessage: String { @@ -303,7 +311,7 @@ final class AppModel: ObservableObject, Identifiable, UnsavedDocumentHandling { activeModuleContributions.filter { $0.placement == .activityBar } } var rightSidebarContributions: [ModuleContribution] { - activeModuleContributions.filter { $0.placement == .rightSidebar } + AgentConversationEntryPolicy.rightSidebarContributions(from: activeModuleContributions) } var workspaceFileOperations: any WorkspaceFileOperations { services.fileOperations } func fileExists(at url: URL) -> Bool { services.fileStorage.fileExists(at: url) } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift index bcf288511..27b70633f 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModelSupportTypes.swift @@ -11,6 +11,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case terminal = "Terminal" case lsp = "LSP" case ai = "AI & Commit" + case providers = "AI Providers" case git = "Git" case updates = "Updates" case diagnostics = "Diagnostics" @@ -28,6 +29,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { case .terminal: "terminal" case .lsp: "server.rack" case .ai: "wand.and.stars" + case .providers: "key" case .git: "arrow.triangle.branch" case .updates: "arrow.down.circle" case .diagnostics: "stethoscope" diff --git a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift index b4a7d27e2..9fe0acaef 100644 --- a/macos/Sources/Lithe/Models/Editor/EditorDocument.swift +++ b/macos/Sources/Lithe/Models/Editor/EditorDocument.swift @@ -82,6 +82,8 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable /// single-encoding document model. New code must choose a role explicitly. var encoding: DocumentEncoding { readEncoding } private(set) var diskIdentity: String? + /// Invalidates the tab's content-based icon after disk writes or watcher events. + @Published private(set) var iconContentRevision: UInt64 = 0 private(set) var externalDiskIdentity: String? @Published private(set) var savedText: String private(set) var lifecycleState: DocumentLifecycleState @@ -95,6 +97,10 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable hasObservedDiskConflict && externalDiskContent == nil && externalDiskIdentity == nil } + func invalidateIconContent() { + iconContentRevision &+= 1 + } + func observeDiskConflict(_ content: String?, identity: String? = nil) { objectWillChange.send() externalDiskContent = content @@ -270,6 +276,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable self.saveEncoding = encoding self.storageEncoding = encoding diskIdentity = identity + invalidateIconContent() } func updateReadEncoding(_ encoding: DocumentEncoding) { @@ -288,6 +295,7 @@ final class EditorDocument: ObservableObject, Identifiable, @unchecked Sendable readEncoding = encoding } diskIdentity = identity + invalidateIconContent() hasAcknowledgedDiskContent = false hasObservedDiskConflict = false lifecycleState = .clean(revision: lifecycleState.revision + 1) diff --git a/macos/Sources/Lithe/Models/Settings/AppSettings.swift b/macos/Sources/Lithe/Models/Settings/AppSettings.swift index 570d121fc..594d3b5f8 100644 --- a/macos/Sources/Lithe/Models/Settings/AppSettings.swift +++ b/macos/Sources/Lithe/Models/Settings/AppSettings.swift @@ -33,6 +33,9 @@ final class AppSettings: ObservableObject { static let gitSaveChangesPolicy = "settings.gitSaveChangesPolicy" static let projectOpenBehavior = "settings.projectOpenBehavior" static let commitMessageAI = "settings.commitMessageAI" + static let agentCommand = "settings.agentCommand" + static let agentArguments = "settings.agentArguments" + static let agentConfigurations = "settings.agentConfigurations" static let keyboardShortcutOverrides = "settings.keyboardShortcutOverrides" static let customLogDirectory = "settings.customLogDirectory" static let workbenchBackground = "settings.workbenchBackground" @@ -121,6 +124,19 @@ final class AppSettings: ObservableObject { @Published var commitMessageAI: CommitMessageAISettings { didSet { saveCommitMessageAI() } } + @Published var agentCommand: String { didSet { defaults.set(agentCommand, forKey: Key.agentCommand) } } + /// One command argument per line, so paths with spaces need no shell parser. + @Published var agentArguments: String { didSet { defaults.set(agentArguments, forKey: Key.agentArguments) } } + /// AI provider whose endpoint and API key the Agent uses; `nil` until chosen. + /// Per-agent setup from Settings › Agents, keyed by ACP registry id or + /// `AgentConfiguration.customAgentID`. + @Published var agentConfigurations: [String: AgentConfiguration] { + didSet { + if let data = try? JSONEncoder().encode(agentConfigurations) { + defaults.set(data, forKey: Key.agentConfigurations) + } + } + } @Published private(set) var keyboardShortcutOverrides: [String: [KeyboardShortcutBinding]] @Published private(set) var customLogDirectory: URL? @Published private(set) var workbenchBackground: WorkbenchBackgroundConfiguration @@ -197,6 +213,10 @@ final class AppSettings: ObservableObject { } else { commitMessageAI = .default } + agentCommand = defaults.string(forKey: Key.agentCommand) ?? "" + agentArguments = defaults.string(forKey: Key.agentArguments) ?? "" + agentConfigurations = defaults.data(forKey: Key.agentConfigurations) + .flatMap { try? JSONDecoder().decode([String: AgentConfiguration].self, from: $0) } ?? [:] if let data = defaults.data(forKey: Key.javaBuildFailurePolicies), let saved = try? JSONDecoder().decode([String: JavaBuildFailurePolicy].self, from: data) { javaBuildFailurePolicies = saved @@ -329,6 +349,9 @@ final class AppSettings: ObservableObject { gitSaveChangesPolicy = .stash projectOpenBehavior = .ask commitMessageAI = .default + agentCommand = "" + agentArguments = "" + agentConfigurations = [:] setCustomLogDirectory(nil) clearWorkbenchBackground() workbenchBackgroundOpacity = 0.22 @@ -342,6 +365,36 @@ final class AppSettings: ObservableObject { saveKeyboardShortcutOverrides() } + /// Providers an agent speaking `apiProtocol` can use. + func agentProviderCandidates(for apiProtocol: CommitMessageAPIProtocol) -> [AIProviderProfile] { + commitMessageAI.providers.filter { $0.apiProtocol == apiProtocol } + } + + /// The provider assigned to `agentID`, if it still exists. + func agentProvider(for agentID: String) -> AIProviderProfile? { + guard let providerID = agentConfigurations[agentID]?.providerID else { return nil } + return commitMessageAI.providers.first { $0.id == providerID } + } + + func setAgentProvider(_ providerID: UUID?, for agentID: String, name: String) { + agentConfigurations[agentID] = AgentConfiguration(name: name, providerID: providerID) + } + + /// Refresh linked agents' default models without changing the commit provider + /// selection, endpoints, credentials, or manually configured models. + func refreshAgentModels(from configurations: [AIConfigurationModel]) { + let linkedIDs = Set(agentConfigurations.values.compactMap(\.providerID)) + var value = commitMessageAI + for index in value.providers.indices { + let provider = value.providers[index] + guard linkedIDs.contains(provider.id), + let source = provider.credentialSource.configurationSource, + let snapshot = configurations.first(where: { $0.source == source }) else { continue } + value.providers[index].model = snapshot.model + } + if value != commitMessageAI { commitMessageAI = value } + } + var activeCommitMessageProvider: AIProviderProfile? { commitMessageAI.activeProvider } @@ -365,6 +418,37 @@ final class AppSettings: ObservableObject { commitMessageAI = value } + /// Edit any provider without changing which one commit messages use. + func updateAIProvider(_ id: UUID, _ update: (inout AIProviderProfile) -> Void) { + var value = commitMessageAI + guard let index = value.providers.firstIndex(where: { $0.id == id }) else { return } + update(&value.providers[index]) + commitMessageAI = value + } + + /// Add a provider; commit messages switch to it only when none was selected. + @discardableResult + func addAIProvider() -> UUID { + var value = commitMessageAI + let previous = value.activeProviderID + let provider = value.addProvider() + if previous != nil { value.activeProviderID = previous } + value.codexImportCompleted = true + commitMessageAI = value + return provider.id + } + + /// Remove a provider and every reference to it. + func removeAIProvider(_ id: UUID) { + var value = commitMessageAI + value.providers.removeAll { $0.id == id } + if value.activeProviderID == id { value.activeProviderID = value.providers.first?.id } + commitMessageAI = value + for (agentID, configuration) in agentConfigurations where configuration.providerID == id { + agentConfigurations[agentID]?.providerID = nil + } + } + func removeActiveCommitMessageProvider() { var value = commitMessageAI value.removeActiveProvider() @@ -731,3 +815,14 @@ enum TerminalShell: String, CaseIterable, Identifiable { } } } + +/// Agent setup chosen in Settings › Agents. +struct AgentConfiguration: Codable, Equatable { + /// Identifier of the user-provided command agent configured by + /// `agentCommand` and `agentArguments`. + static let customAgentID = "custom" + + /// Display name recorded when the agent was set up, for the Agent panel. + var name: String + var providerID: UUID? +} diff --git a/macos/Sources/Lithe/Platform/MacOS/AI/MacAgentProviderConfigurationParser.swift b/macos/Sources/Lithe/Platform/MacOS/AI/MacAgentProviderConfigurationParser.swift new file mode 100644 index 000000000..43bc686a5 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/AI/MacAgentProviderConfigurationParser.swift @@ -0,0 +1,55 @@ +import Foundation +import LitheCoreContracts + +/// Shared TOML/JSON metadata parsing plus native, ephemeral credential extraction. +struct MacAgentProviderConfigurationParser: AgentProviderConfigurationParsing { + let core: RustCoreBridge + + private struct Payload: Encodable { + let source: String + let configuration: String + } + private struct Metadata: Decodable { + let name: String + let endpoint: String + let model: String + let apiProtocol: CommitMessageAPIProtocol + let authentication: AIProviderAuthentication + } + + func parse(source: AIConfigurationSourceKind, configuration: String, authentication: String) throws -> AIConfigurationSnapshot { + guard configuration.utf8.count <= 64 * 1024, authentication.utf8.count <= 64 * 1024 else { + throw AgentProviderConfigurationError.invalidConfiguration + } + let metadata: Metadata + do { + metadata = try core.executeResult(command: "agent.parseProviderConfiguration", payload: Payload( + source: source.rawValue, configuration: configuration + )).get() + } catch { + // Never present parser details, which could quote a pasted credential. + if source == .codex, configuration.contains("“") || configuration.contains("”") { + throw AgentProviderConfigurationError.invalidConfigurationQuotes + } + throw AgentProviderConfigurationError.invalidConfiguration + } + let key: String? + switch source { + case .codex: + guard let object = try? JSONSerialization.jsonObject(with: Data(authentication.utf8)) as? [String: Any], + let value = (object["OPENAI_API_KEY"] ?? object["api_key"]) as? String else { + throw AgentProviderConfigurationError.invalidAuthentication + } + key = value + case .claude: + let snapshot = MacClaudeConfigurationParser.parse(settingsData: Data(configuration.utf8), + credentialsData: nil, rootConfigData: nil, environment: [:]) + guard snapshot?.hasAPIKey == true else { throw AgentProviderConfigurationError.missingAPIKey } + guard snapshot?.authentication == .apiKey else { throw AgentProviderConfigurationError.unsupportedProtocol } + key = snapshot?.apiKey + } + return AIConfigurationSnapshot(source: source, providerName: metadata.name, endpoint: metadata.endpoint, + model: metadata.model, apiProtocol: metadata.apiProtocol, authentication: metadata.authentication, + reasoningEffort: nil, requiresAPIKey: true, apiKey: key) + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift b/macos/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift index 0aff83f86..52cd280cf 100644 --- a/macos/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift +++ b/macos/Sources/Lithe/Platform/MacOS/AI/MacCodexConfigurationSource.swift @@ -8,6 +8,14 @@ final class MacCodexConfigurationSource: CodexConfigurationSource, @unchecked Se self.fileManager = fileManager } + func loadModel() -> AIConfigurationModel? { + let configURL = fileManager.homeDirectoryForCurrentUser + .appendingPathComponent(".codex", isDirectory: true) + .appendingPathComponent("config.toml") + guard let config = try? String(contentsOf: configURL, encoding: .utf8) else { return nil } + return AIConfigurationModel(source: .codex, model: MacCodexConfigurationParser.parseModel(config: config)) + } + func load() -> CodexConfigurationSnapshot? { let home = fileManager.homeDirectoryForCurrentUser let configURL = home @@ -44,36 +52,7 @@ final class MacCodexConfigurationSource: CodexConfigurationSource, @unchecked Se enum MacCodexConfigurationParser { static func parse(config: String, authData: Data?) -> CodexConfigurationSnapshot? { - var topLevel: [String: String] = [:] - var providerValues: [String: [String: String]] = [:] - var currentProvider: String? - - for rawLine in config.split(whereSeparator: { $0.isNewline }) { - let line = stripComment(String(rawLine)).trimmingCharacters(in: .whitespacesAndNewlines) - guard !line.isEmpty else { continue } - - if line.hasPrefix("[") && line.hasSuffix("]") { - let section = String(line.dropFirst().dropLast()) - if section.hasPrefix("model_providers.") { - currentProvider = String(section.dropFirst("model_providers.".count)) - providerValues[currentProvider!, default: [:]] = [:] - } else { - currentProvider = nil - } - continue - } - - let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) - guard parts.count == 2 else { continue } - let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) - let value = parseValue(String(parts[1])) - if let currentProvider { - providerValues[currentProvider, default: [:]][key] = value - } else { - topLevel[key] = value - } - } - + let (topLevel, providerValues) = parseValues(config: config) let providerName = topLevel["model_provider"] ?? "custom" guard let provider = providerValues[providerName], let endpoint = provider["base_url"], @@ -104,6 +83,48 @@ enum MacCodexConfigurationParser { ) } + static func parseModel(config: String) -> String { + // A present config without a top-level model uses the CLI's default. + parseValues(config: config).topLevel["model"] ?? "" + } + + private static func parseValues(config: String) -> ( + topLevel: [String: String], providerValues: [String: [String: String]] + ) { + var topLevel: [String: String] = [:] + var providerValues: [String: [String: String]] = [:] + var currentProvider: String? + var isTopLevel = true + + for rawLine in config.split(whereSeparator: { $0.isNewline }) { + let line = stripComment(String(rawLine)).trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { continue } + + if line.hasPrefix("[") && line.hasSuffix("]") { + isTopLevel = false + let section = String(line.dropFirst().dropLast()) + if section.hasPrefix("model_providers.") { + currentProvider = String(section.dropFirst("model_providers.".count)) + providerValues[currentProvider!, default: [:]] = [:] + } else { + currentProvider = nil + } + continue + } + + let parts = line.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + guard parts.count == 2 else { continue } + let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines) + let value = parseValue(String(parts[1])) + if let currentProvider { + providerValues[currentProvider, default: [:]][key] = value + } else if isTopLevel { + topLevel[key] = value + } + } + return (topLevel, providerValues) + } + private static func parseValue(_ rawValue: String) -> String { let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) guard value.count >= 2, diff --git a/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift b/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift new file mode 100644 index 000000000..e2bdb0089 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Agent/MacACPAgentTransport.swift @@ -0,0 +1,112 @@ +import Foundation +import LitheCoreContracts +import LitheRustCore + +@MainActor +final class MacACPAgentTransport: AgentConversationTransport { + func open( + configuration: AgentLaunchConfiguration, + onEvent: @escaping @Sendable (String) -> Void + ) throws -> any AgentConnection { + var configurationJSON: [String: Any] = [ + "args": configuration.arguments, + "cwd": configuration.workspaceURL.path, + "dataDirectory": configuration.dataDirectory.path, + "provider": [ + "protocol": configuration.providerProtocol, + "baseUrl": configuration.providerEndpoint, + "apiKey": configuration.apiKey, + "name": configuration.providerName, + "model": configuration.model, + "allowInsecureHttp": configuration.allowsInsecureHTTP + ] + ] + if let agentID = configuration.agentID { + configurationJSON["agentId"] = agentID + } else { + configurationJSON["command"] = configuration.command + } + let data = try JSONSerialization.data(withJSONObject: configurationJSON) + let json = String(decoding: data, as: UTF8.self) + let callback = AgentEventCallback(onEvent: onEvent) + let context = Unmanaged.passRetained(callback).toOpaque() + let handle = json.withCString { lithe_bridge_agent_open_json($0, macACPEventCallback, context) } + guard let handle else { + Unmanaged.fromOpaque(context).release() + throw MacACPAgentError.invalidConfiguration + } + return MacACPAgentConnection(handle: handle, context: context) + } +} + +private final class AgentEventCallback: @unchecked Sendable { + let onEvent: @Sendable (String) -> Void + init(onEvent: @escaping @Sendable (String) -> Void) { self.onEvent = onEvent } +} + +private func macACPEventCallback(_ event: UnsafePointer?, _ context: UnsafeMutableRawPointer?) { + guard let event, let context else { return } + let callback = Unmanaged.fromOpaque(context).takeUnretainedValue() + callback.onEvent(String(cString: event)) +} + +@MainActor +private final class MacACPAgentConnection: AgentConnection { + private var handle: UnsafeMutableRawPointer? + private var context: UnsafeMutableRawPointer? + + init(handle: UnsafeMutableRawPointer, context: UnsafeMutableRawPointer) { + self.handle = handle + self.context = context + } + + func send(commandJSON: String) throws { + guard let handle, commandJSON.withCString({ lithe_bridge_agent_send_json(handle, $0) }) == 1 else { + throw MacACPAgentError.rejected + } + } + + /// Closing blocks until the agent tree exits (bounded by the host), so it + /// runs off the main thread. + func close() async { + guard let handle, let context else { return } + self.handle = nil + self.context = nil + let handleAddress = Int(bitPattern: handle) + let contextAddress = Int(bitPattern: context) + await withCheckedContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + lithe_bridge_agent_close(UnsafeMutableRawPointer(bitPattern: handleAddress)) + if let pointer = UnsafeMutableRawPointer(bitPattern: contextAddress) { + Unmanaged.fromOpaque(pointer).release() + } + continuation.resume() + } + } + } + + deinit { + // Normal paths call `close()`; this only guards an abandoned connection. + guard let handle, let context else { return } + let handleAddress = Int(bitPattern: handle) + let contextAddress = Int(bitPattern: context) + DispatchQueue.global(qos: .utility).async { + lithe_bridge_agent_close(UnsafeMutableRawPointer(bitPattern: handleAddress)) + if let pointer = UnsafeMutableRawPointer(bitPattern: contextAddress) { + Unmanaged.fromOpaque(pointer).release() + } + } + } +} + +private enum MacACPAgentError: LocalizedError { + case invalidConfiguration + case rejected + + var errorDescription: String? { + switch self { + case .invalidConfiguration: "The Agent configuration is invalid. Check the executable, API endpoint, and key." + case .rejected: "The Agent did not accept the request. It may have stopped." + } + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift b/macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift new file mode 100644 index 000000000..e11a02201 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift @@ -0,0 +1,57 @@ +import AppKit +import CryptoKit +import Foundation +import LitheCoreContracts +import UniformTypeIdentifiers + +/// Metadata lives in platform preferences, never in an installed bundle or Agent files. +@MainActor +struct MacAgentHistoryPersistence: AgentHistoryPersisting { + let store: any KeyValueStore + + func load(workspaceURL: URL, agentID: String) throws -> [String: AgentHistoryMetadata] { + guard let data = store.data(forKey: key(workspaceURL, agentID)) else { return [:] } + return try JSONDecoder().decode([String: AgentHistoryMetadata].self, from: data) + } + + func save(_ metadata: [String: AgentHistoryMetadata], workspaceURL: URL, agentID: String) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + store.set(try encoder.encode(metadata), forKey: key(workspaceURL, agentID)) + } + + private func key(_ workspaceURL: URL, _ agentID: String) -> String { + let identity = workspaceURL.standardizedFileURL.path + "\u{0}" + agentID + let digest = SHA256.hash(data: Data(identity.utf8)).map { String(format: "%02x", $0) }.joined() + return "lithe.agent-history.v1.\(digest)" + } +} + +/// Export destination is chosen by the user. No transcript cache or bundle writes. +@MainActor +struct MacAgentHistoryExporter: AgentHistoryExporting { + let storage: any FileStorage + + func chooseDestination() -> URL? { + let panel = NSSavePanel() + panel.title = String(localized: "Export conversations") + panel.allowedContentTypes = [UTType(filenameExtension: "md") ?? .plainText] + panel.nameFieldStringValue = "agent-conversations.md" + panel.canCreateDirectories = true + return panel.runModal() == .OK ? panel.url : nil + } + + func writeMarkdown(_ markdown: String, to url: URL) async throws { + guard !url.resolvingSymlinksInPath().pathComponents.contains(where: { $0.hasSuffix(".app") }) else { + throw CocoaError(.fileWriteNoPermission) + } + let data = Data(markdown.utf8) + guard data.count <= 32 * 1024 * 1024 else { throw CocoaError(.fileWriteOutOfSpace) } + try await Task.detached(priority: .userInitiated) { [storage] in + try Task.checkCancellation() + let accessed = url.startAccessingSecurityScopedResource() + defer { if accessed { url.stopAccessingSecurityScopedResource() } } + try storage.writeData(data, to: url, options: .atomic) + }.value + } +} diff --git a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift index e4e09a33e..47e3caf5e 100644 --- a/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift +++ b/macos/Sources/Lithe/Platform/MacOS/MacServiceContainer.swift @@ -1,5 +1,6 @@ import Foundation import LitheAIAssistanceModule +import LitheAgentConversationModule import LitheApplicationKernel import LitheCoreContracts import LitheDatabaseModule @@ -180,6 +181,16 @@ final class MacServiceContainer { credentialResolver: credentialResolver ) }) + try moduleRegistry.register(ModuleFactory( + manifest: AgentConversationModule.moduleManifest, + contributions: AgentConversationModule.moduleContributions + ) { + AgentConversationModule( + transportFactory: { MacACPAgentTransport() }, + historyPersistence: MacAgentHistoryPersistence(store: store), + historyExporter: MacAgentHistoryExporter(storage: fileStorage) + ) + }) try moduleRegistry.register(ModuleFactory(manifest: DatabaseModule.moduleManifest, contributions: DatabaseModule.moduleContributions) { DatabaseModule( processRunner: processRunner, @@ -624,6 +635,8 @@ final class MacServiceContainer { databaseSecureStore: databaseSecureStore, diagnosticsExportService: diagnosticsExportService, credentialResolver: credentialResolver, + agentManagement: RustAgentManagementService(core: rustCore), + agentProviderConfigurationParser: MacAgentProviderConfigurationParser(core: rustCore), aiConfigurationSources: aiConfigurationSources, recentProjectsStore: RecentProjectsStore(store: store), workspaceSessionStore: WorkspaceSessionStore(store: store), diff --git a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLocalSecretStore.swift b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLocalSecretStore.swift index 1365c619e..f85318a77 100644 --- a/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLocalSecretStore.swift +++ b/macos/Sources/Lithe/Platform/MacOS/Persistence/MacLocalSecretStore.swift @@ -45,10 +45,7 @@ final class MacLocalSecretStore: SecureStore, @unchecked Sendable { lock.lock() defer { lock.unlock() } - var contents = (try? loadContents()) ?? FileContents( - version: Self.fileVersion, - values: [:] - ) + var contents = try loadContents() let data = transform(Data(value.utf8), key: key) contents.values[key] = data.base64EncodedString() try saveContents(contents) @@ -58,7 +55,7 @@ final class MacLocalSecretStore: SecureStore, @unchecked Sendable { lock.lock() defer { lock.unlock() } - guard var contents = try? loadContents() else { return } + var contents = try loadContents() contents.values.removeValue(forKey: key) if contents.values.isEmpty { if fileManager.fileExists(atPath: fileURL.path) { @@ -76,11 +73,18 @@ final class MacLocalSecretStore: SecureStore, @unchecked Sendable { let data = try Data(contentsOf: fileURL) let contents = try JSONDecoder().decode(FileContents.self, from: data) guard contents.version == Self.fileVersion else { - return FileContents(version: Self.fileVersion, values: [:]) + throw StoreError.unsupportedVersion } return contents } + private enum StoreError: LocalizedError { + case unsupportedVersion + var errorDescription: String? { + String(localized: "The credential store version is unsupported. Existing credentials were preserved.") + } + } + private func saveContents(_ contents: FileContents) throws { let directoryURL = fileURL.deletingLastPathComponent() try fileManager.createDirectory( diff --git a/macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift b/macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift new file mode 100644 index 000000000..554ca62b7 --- /dev/null +++ b/macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift @@ -0,0 +1,83 @@ +import AppKit +import SwiftUI + +/// Plain configuration input, using the same TextKit policy as the source and SQL editors. +struct MacConfigurationTextEditor: NSViewRepresentable { + @Binding var text: String + let label: String + + func makeCoordinator() -> Coordinator { Coordinator(text: $text) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + let editor = MacConfigurationTextView() + editor.string = text + editor.delegate = context.coordinator + editor.setAccessibilityLabel(label) + scroll.documentView = editor + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.text = $text + guard let editor = scroll.documentView as? MacConfigurationTextView else { return } + editor.isEditable = context.environment.isEnabled + editor.textColor = NSColor(LitheTheme.primaryText) + editor.insertionPointColor = NSColor(LitheTheme.primaryText) + // Normal binding updates must not interrupt IME composition or reset the caret. + if editor.string != text, !editor.hasMarkedText() { + let selection = editor.selectedRange() + editor.string = text + let location = min(selection.location, text.utf16.count) + editor.setSelectedRange(NSRange(location: location, length: min(selection.length, text.utf16.count - location))) + } + } + + static func dismantleNSView(_ scroll: NSScrollView, coordinator: Coordinator) { + (scroll.documentView as? NSTextView)?.delegate = nil + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var text: Binding + init(text: Binding) { self.text = text } + func textDidChange(_ notification: Notification) { + guard let editor = notification.object as? NSTextView else { return } + text.wrappedValue = editor.string + } + } +} + +/// Configuration syntax must retain the literal characters the user types. +final class MacConfigurationTextView: NSTextView { + init() { + let storage = NSTextStorage() + let layout = NSLayoutManager() + let container = NSTextContainer(size: .zero) + storage.addLayoutManager(layout) + layout.addTextContainer(container) + super.init(frame: .zero, textContainer: container) + isRichText = false + importsGraphics = false + allowsUndo = true + drawsBackground = false + font = .monospacedSystemFont(ofSize: 11, weight: .regular) + textContainerInset = NSSize(width: 6, height: 6) + isVerticallyResizable = true + isHorizontallyResizable = false + autoresizingMask = [.width] + minSize = .zero + maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + textContainer?.widthTracksTextView = true + textContainer?.containerSize = NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude) + isAutomaticQuoteSubstitutionEnabled = false + isAutomaticDashSubstitutionEnabled = false + isAutomaticTextReplacementEnabled = false + isAutomaticSpellingCorrectionEnabled = false + isContinuousSpellCheckingEnabled = false + } + + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } +} diff --git a/macos/Sources/Lithe/Resources/AgentIcons/LICENSE.txt b/macos/Sources/Lithe/Resources/AgentIcons/LICENSE.txt new file mode 100644 index 000000000..1dd53d2a9 --- /dev/null +++ b/macos/Sources/Lithe/Resources/AgentIcons/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 LobeHub + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/macos/Sources/Lithe/Resources/AgentIcons/NOTICE.txt b/macos/Sources/Lithe/Resources/AgentIcons/NOTICE.txt new file mode 100644 index 000000000..150f500cc --- /dev/null +++ b/macos/Sources/Lithe/Resources/AgentIcons/NOTICE.txt @@ -0,0 +1,7 @@ +OpenAI and Claude silhouettes are from @lobehub/icons 5.8.0: +es/OpenAI/components/Mono.js and es/Claude/components/Mono.js. +This is the version pinned by jetbrains-cc-gui's webview/package-lock.json. +SVG paths are unchanged; currentColor is black for AppKit template rendering. +Upstream: https://github.com/lobehub/lobe-icons +The accompanying MIT license applies to these assets. +Brand marks identify the selected agent; no affiliation is implied. diff --git a/macos/Sources/Lithe/Resources/AgentIcons/claude.svg b/macos/Sources/Lithe/Resources/AgentIcons/claude.svg new file mode 100644 index 000000000..68937a75c --- /dev/null +++ b/macos/Sources/Lithe/Resources/AgentIcons/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/macos/Sources/Lithe/Resources/AgentIcons/openai.svg b/macos/Sources/Lithe/Resources/AgentIcons/openai.svg new file mode 100644 index 000000000..62c9ed5a2 --- /dev/null +++ b/macos/Sources/Lithe/Resources/AgentIcons/openai.svg @@ -0,0 +1,3 @@ + + + diff --git a/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift b/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift index 750612789..3afaa1bf0 100644 --- a/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift +++ b/macos/Sources/Lithe/Services/Workbench/WorkbenchLayoutStore.swift @@ -4,7 +4,7 @@ struct WorkbenchLayout: Codable, Sendable { static let minimumSidebarWidth: Double = 30 static let defaultMavenPaneWidth: Double = 360 static let minimumMavenPaneWidth: Double = 300 - static let maximumMavenPaneWidth: Double = 520 + static let maximumMavenPaneWidth: Double = 760 let sidebarWidth: Double let topPaneHeight: Double? let mavenPaneWidth: Double? diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift new file mode 100644 index 000000000..d3fc09dbe --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -0,0 +1,228 @@ +import SwiftUI +import UniformTypeIdentifiers +import LitheAgentConversationModule + +/// Context strip, flexible writing area, and a compact bottom command bar. +struct AgentComposerView: View { + let agents: [AgentOption] + let selectedAgent: AgentOption? + let isResponding: Bool + let isBlocked: Bool + let onSend: (String, [AgentFileReference]) throws -> Void + let onCancel: () -> Void + let onSelectAgent: (String) -> Void + let onOpenSettings: () -> Void + let onError: (String?) -> Void + var configOptions: [AgentSessionConfigOption] = [] + var sessionID: String? + var isConfiguring = false + var isCancelling = false + var contextUsage: AgentContextUsage? + var onSetConfig: (String, String) -> Void = { _, _ in } + @State private var draft = "" + @State private var files: [AgentFileReference] = [] + @State private var isDropTargeted = false + @State private var showsFilePicker = false + @State private var isHovering = false + @FocusState private var isFocused: Bool + + private var hasContent: Bool { !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + + var body: some View { + VStack(spacing: 0) { + contextBar + if !files.isEmpty { + AgentFileReferenceList(files: files) { id in files.removeAll { $0.id == id } } + } + ScrollView { + TextField("Message the Agent", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1...) + .focused($isFocused) + .onSubmit(send) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture { isFocused = true } + toolbar + } + .background(AgentPanelStyle.canvas, in: RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(isFocused || isHovering || isDropTargeted ? AgentPanelStyle.focus : AgentPanelStyle.secondary.opacity(0.45), lineWidth: 1) + .allowsHitTesting(false) + } + .dropDestination(for: URL.self) { urls, _ in + addFiles(urls) + } isTargeted: { isDropTargeted = $0 } + .fileImporter(isPresented: $showsFilePicker, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in + switch result { + case .success(let urls): _ = addFiles(urls) + case .failure(let error): onError(error.localizedDescription) + } + } + .onChange(of: sessionID) { newSessionID in + if newSessionID != nil { files.removeAll() } + } + .onHover { isHovering = $0 } + .padding(.horizontal, 8) + .padding(.bottom, 8) + .onAppear { isFocused = true } + .onExitCommand { if isResponding { onCancel() } } + } + + private var contextBar: some View { + HStack(spacing: 8) { + AgentContextUsageView(usage: contextUsage) + Rectangle().fill(AgentPanelStyle.border).frame(width: 1, height: 12) + Button { showsFilePicker = true } label: { + Label(isDropTargeted ? "Drop files here" : "Attach files", systemImage: "paperclip") + } + .buttonStyle(.plain) + .help("Drag files here or click to choose files") + Spacer(minLength: 0) + } + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 10) + .frame(height: 28) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) + .padding(1) + } + + private var toolbar: some View { + HStack(spacing: 4) { + Button(action: onOpenSettings) { Image(systemName: "slider.horizontal.3") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Agent Settings") + agentMenu + if !configOptions.isEmpty { + AgentSessionSelectors( + options: configOptions, + agentName: selectedAgent?.name, + isDisabled: isBlocked || isResponding || isConfiguring, + onSelect: onSetConfig + ) + .id(sessionID ?? selectedAgent?.id) + if isConfiguring { ProgressView().controlSize(.mini) } + } + if configOptions.isEmpty, let model = selectedAgent?.modelName, !model.isEmpty { + HStack(spacing: 5) { + AgentBrandIcon(name: selectedAgent?.name, size: 12) + Text(model).lineLimit(1).truncationMode(.middle) + } + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 4) + .help(model) + } + Spacer(minLength: 0) + Button(action: isResponding ? onCancel : send) { + Image(systemName: isResponding ? "stop.fill" : "paperplane") + .font(.system(size: 13)) + .foregroundStyle(isResponding ? LitheTheme.error : (hasContent ? AgentPanelStyle.text : AgentPanelStyle.muted)) + .frame(width: 26, height: 26) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 4)) + } + .buttonStyle(.plain) + .lithePointer() + .disabled(isCancelling || (!isResponding && (!hasContent || isBlocked || isConfiguring))) + .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) + } + .padding(.horizontal, 5) + .frame(height: 36) + .background(AgentPanelStyle.toolbar, in: RoundedRectangle(cornerRadius: 7)) + .padding(1) + } + + private var agentMenu: some View { + Menu { + if agents.isEmpty { Text("No Agent is set up yet") } + ForEach(agents) { agent in + Button { onSelectAgent(agent.id) } label: { + if agent.id == selectedAgent?.id { + Label(agent.name, systemImage: "checkmark") + } else { + Text(agent.name) + } + } + } + Divider() + Button("Agent Settings…", action: onOpenSettings) + } label: { + AgentBrandIcon(name: selectedAgent?.name, size: 16) + .foregroundStyle(AgentPanelStyle.secondary) + .frame(width: 28, height: 28) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help(selectedAgent?.name ?? String(localized: "Choose an Agent")) + .accessibilityLabel("Switch Agent") + } + + private func addFiles(_ urls: [URL]) -> Bool { + guard !urls.isEmpty else { return false } + do { + files = try AgentFileReference.adding(urls, to: files) + onError(nil) + isFocused = true + return true + } catch { + onError(error.localizedDescription) + return false + } + } + + private func send() { + guard hasContent, !isResponding else { return } + if isBlocked { + onError(String(localized: "The conversation is still being prepared. Try again in a moment.")) + return + } + do { + try onSend(draft, files) + draft = "" + files.removeAll() + onError(nil) + } catch { + onError(error.localizedDescription) + } + } + +} + +/// The shared split container keeps resize updates outside the conversation model. +struct AgentConversationLayout: View { + @ViewBuilder let transcript: Transcript + @ViewBuilder let composer: Composer + + var body: some View { + GeometryReader { geometry in + LitheSplitPaneView( + axis: .vertical, + placement: .trailing, + defaultSize: min(210, geometry.size.height * 0.3), + minimum: min(120, geometry.size.height * 0.4), + maximum: max(0, geometry.size.height * 0.6), + showsIdleDivider: false + ) { + composer + .padding(.top, 10) + .overlay(alignment: .top) { + Capsule().fill(AgentPanelStyle.muted.opacity(0.55)) + .frame(width: 54, height: 3) + .offset(y: -4) + .allowsHitTesting(false) + } + } flexible: { + transcript + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift b/macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift new file mode 100644 index 000000000..900dfb658 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import LitheAgentConversationModule + +enum AgentContextUsagePresentation { + static func percentage(_ usage: AgentContextUsage?, locale: Locale, precise: Bool = false) -> String { + let fraction = usage?.fraction ?? 0 + return fraction.formatted(.percent.precision(.fractionLength(precise ? 1 : 0)).locale(locale)) + } + + static func details(_ usage: AgentContextUsage?, locale: Locale) -> String { + guard let usage, usage.usedTokens > 0 else { + return String(format: String(localized: "Context: %@"), percentage(usage, locale: locale, precise: true)) + } + let format = FloatingPointFormatStyle.number.precision(.fractionLength(0...1)).locale(locale) + let tokens = { (count: UInt64) in + count >= 1_000 ? (Double(count) / 1_000).formatted(format) + "k" : count.formatted(.number.locale(locale)) + } + return String(format: String(localized: "%@ · %@ / %@ context tokens"), + percentage(usage, locale: locale, precise: true), tokens(usage.usedTokens), tokens(usage.capacityTokens)) + } +} + +/// Compact context ring with a zero placeholder until the Agent reports usage. +struct AgentContextUsageView: View { + let usage: AgentContextUsage? + @Environment(\.locale) private var locale + + private var color: Color { + guard let usage else { return AgentPanelStyle.muted } + if usage.fraction >= 1 { return LitheTheme.error } + if usage.fraction >= 0.9 { return LitheTheme.warning } + return AgentPanelStyle.secondary + } + + var body: some View { + HStack(spacing: 4) { + ZStack { + Circle().stroke(AgentPanelStyle.secondary.opacity(0.3), lineWidth: 1.5) + if let usage { + Circle().trim(from: 0, to: min(usage.fraction, 1)) + .stroke(color, style: StrokeStyle(lineWidth: 1.5, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + }.frame(width: 12, height: 12) + Text(AgentContextUsagePresentation.percentage(usage, locale: locale)) + .monospacedDigit().lineLimit(1) + } + .foregroundStyle(color) + .padding(.vertical, 4) + .fixedSize() + .workbenchHoverHelp(Text(verbatim: AgentContextUsagePresentation.details(usage, locale: locale)), placement: .above) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Context usage") + .accessibilityValue(AgentContextUsagePresentation.details(usage, locale: locale)) + .accessibilityIdentifier("agent-context-usage") + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift new file mode 100644 index 000000000..142168d74 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -0,0 +1,481 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Right-docked Agent panel. It always shows the full conversation layout; +/// sending validates the setup first and points to the in-panel settings. +struct AgentConversationView: View { + @ObservedObject var model: AppModel + @State private var showsSettings = false + + private var feature: AgentConversationFeatureModel? { model.agentConversationFeatureIfActive } + + var body: some View { + VStack(spacing: 0) { + if showsSettings { + AgentPanelSettingsView( + model: model, + settings: model.settings, + feature: model.agentManagementFeature, + onDone: { showsSettings = false } + ) + } else if let feature, let connection = feature.selectedConnection, let agentID = feature.selectedAgentID { + AgentConnectionView( + feature: connection, + history: feature.history(for: agentID), + agents: feature.agents, + selectedAgentID: feature.selectedAgentID, + onSelectAgent: { model.selectAgentConversationAgent($0) }, + onConnect: { model.connectAgentConversation() }, + onOpenSettings: { showsSettings = true }, + onCopySessionID: { model.copyAgentSessionID($0) }, + onOpenFile: { model.openAgentFile($0) } + ) + .id(feature.selectedAgentID) + } else { + AgentUnconfiguredConversationView( + setupError: model.agentConversationSetupError, + onOpenSettings: { showsSettings = true } + ) + } + } + .background(AgentPanelStyle.canvas) + .workbenchHoverTooltipScope() + .onAppear { model.activateAgentConversation() } + } +} + +/// Title on the left and icon actions on the right, like a chat client's +/// session header: new conversation, history, settings. +private struct AgentPanelHeader: View { + let title: String + @ViewBuilder let actions: Actions + + var body: some View { + HStack(spacing: 2) { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + Spacer(minLength: 12) + actions + } + .padding(.leading, 20) + .padding(.trailing, 6) + .frame(height: 44) + .background(AgentPanelStyle.header) + .overlay(alignment: .bottom) { + Rectangle().fill(AgentPanelStyle.border).frame(height: 1) + } + } +} + +/// The full conversation layout shown before any Agent can run. Typing is +/// allowed; sending explains what is missing. +private struct AgentUnconfiguredConversationView: View { + let setupError: AgentConversationError? + let onOpenSettings: () -> Void + @State private var notice: String? + + var body: some View { + AgentPanelHeader(title: String(localized: "New conversation")) { + Button(action: onOpenSettings) { Image(systemName: "gearshape") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Agent Settings") + } + AgentConversationLayout { + VStack(spacing: 0) { + AgentHeroView(agentName: nil, agentVersion: nil, onTap: onOpenSettings) + AgentActivitySummaryBar(messages: []) + if let notice { + AgentInlineNotice(text: notice, actionTitle: "Open Agent Settings", action: onOpenSettings) + } + } + } composer: { + AgentComposerView( + agents: [], + selectedAgent: nil, + isResponding: false, + isBlocked: false, + onSend: { _, _ in throw setupError ?? .moduleStarting }, + onCancel: {}, + onSelectAgent: { _ in }, + onOpenSettings: onOpenSettings, + onError: { notice = $0 } + ) + } + .onAppear { notice = setupError?.localizedDescription } + .onChange(of: setupError) { notice = $0?.localizedDescription } + } +} + +private struct AgentConnectionView: View { + @ObservedObject var feature: AgentConnectionModel + @ObservedObject var history: AgentHistoryFeatureModel + let agents: [AgentOption] + let selectedAgentID: String? + let onSelectAgent: (String) -> Void + let onConnect: () -> Void + let onOpenSettings: () -> Void + let onCopySessionID: (String) -> Void + let onOpenFile: (AgentToolDetails.Location) -> Void + @State private var localError: String? + @State private var showsSearch = false + @State private var searchText = "" + @State private var showsTabs = false + @State private var showsHistory = false + + private var selectedAgent: AgentOption? { agents.first { $0.id == selectedAgentID } } + + var body: some View { + ZStack { + conversation + .opacity(showsHistory ? 0 : 1) + .allowsHitTesting(!showsHistory) + .accessibilityHidden(showsHistory) + if showsHistory { + AgentHistoryView(feature: feature, history: history, agentName: selectedAgent?.name, + onBack: { showsHistory = false }, onCopySessionID: onCopySessionID, + onSelect: { feature.selectSession($0); showsHistory = false }, + onReconnect: onConnect) + } + } + .onAppear { feature.prepareConversation() } + .onChange(of: feature.connectionState) { state in + if state == .ready { feature.prepareConversation() } + } + } + + private var conversation: some View { + VStack(spacing: 0) { + AgentPanelHeader(title: headerTitle) { + Button { showsSearch.toggle(); searchText = "" } label: { Image(systemName: "magnifyingglass") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Search conversation") + Button { feature.startNewConversation() } label: { Image(systemName: "plus") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("New conversation") + .disabled(feature.selectedSessionID == nil) + Button { showsTabs.toggle() } label: { Image(systemName: "rectangle.split.2x1") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Conversation tabs") + Button { showsHistory = true } label: { Image(systemName: "clock.arrow.circlepath") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Conversation history") + .accessibilityIdentifier("agent-history-open") + Button(action: onOpenSettings) { Image(systemName: "gearshape") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Agent Settings") + } + if showsSearch { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").foregroundStyle(AgentPanelStyle.secondary) + TextField("Search conversation", text: $searchText).textFieldStyle(.plain) + Button { showsSearch = false; searchText = "" } label: { Image(systemName: "xmark") } + .buttonStyle(AgentToolbarButtonStyle()) + .help("Close search") + } + .padding(.leading, 12) + .padding(.trailing, 6) + .frame(height: 34) + .background(AgentPanelStyle.context) + } + if showsTabs || feature.openSessionIDs.count > 1 + || (feature.selectedSessionID == nil && !feature.openSessionIDs.isEmpty) { + sessionTabs + } + AgentConversationLayout { + VStack(spacing: 0) { + transcript + if let error = localError ?? feature.selectedConversation?.configurationError ?? feature.selectedConversation?.errorMessage ?? feature.errorMessage { + AgentInlineNotice(text: error) + } + } + } composer: { + AgentComposerView( + agents: agents, + selectedAgent: selectedAgent, + isResponding: feature.selectedConversation?.isResponding == true, + isBlocked: feature.isCreatingSession + || feature.selectedConversation?.isLoading == true + || feature.connectionState != .ready, + onSend: { try feature.send($0, files: $1) }, + onCancel: { feature.cancel() }, + onSelectAgent: onSelectAgent, + onOpenSettings: onOpenSettings, + onError: { localError = $0 }, + configOptions: feature.selectedConversation?.configOptions ?? [], + sessionID: feature.selectedSessionID, + isConfiguring: feature.selectedConversation?.pendingConfigToken != nil, + isCancelling: feature.selectedConversation?.isCancelling == true, + contextUsage: feature.selectedConversation?.contextUsage, + onSetConfig: { feature.setConfigOption($0, value: $1) } + ) + } + } + } + + private var sessionTabs: some View { + AgentSessionTabStrip( + tabs: feature.openSessionIDs.map { id in + AgentSessionTabItem( + id: id, + title: feature.sessions.first { $0.id == id }.map(sessionTitle) ?? String(localized: "Untitled conversation"), + isSelected: feature.selectedSessionID == id, + isBusy: feature.conversations[id]?.isResponding == true, + needsAttention: feature.conversations[id]?.permission != nil + ) + }, + showsNewTab: feature.selectedSessionID == nil, + isNewTabBusy: feature.isCreatingSession, + newTabTitle: feature.pendingNewConversationPrompt.map(AgentSessionTitle.provisional), + onSelect: { feature.selectSession($0) }, + onClose: { feature.closeConversation($0) }, + onNew: { feature.startNewConversation() } + ) + } + + @ViewBuilder + private var transcript: some View { + switch feature.connectionState { + case .idle, .connecting: + AgentEmptyStateView( + systemImage: "sparkles", + title: "Starting the Agent…", + message: String(localized: "The Agent process starts when this panel opens."), + isBusy: true + ) + .onAppear { + if feature.connectionState == .idle { onConnect() } + } + case .failed(let message): + if feature.selectedConversation?.messages.isEmpty == false { + AgentTranscriptView( + feature: feature, agentName: selectedAgent?.name ?? feature.agentName, + agentVersion: feature.agentVersion, agents: agents, + onSelectAgent: onSelectAgent, searchText: searchText, onOpenFile: onOpenFile + ) + AgentInlineNotice(text: message) + Button("Reconnect", action: onConnect).padding(.bottom, 8) + } else { + AgentEmptyStateView( + systemImage: "exclamationmark.triangle", + title: "The Agent could not start", + message: message, + actionTitle: "Retry", + action: onConnect, + secondaryActionTitle: "Agent Settings", + secondaryAction: onOpenSettings + ) + } + case .ready: + AgentTranscriptView( + feature: feature, + agentName: selectedAgent?.name ?? feature.agentName, + agentVersion: feature.agentVersion, + agents: agents, + onSelectAgent: onSelectAgent, + searchText: searchText, + onOpenFile: onOpenFile + ) + } + } + + private var headerTitle: String { + guard let id = feature.selectedSessionID else { return String(localized: "New conversation") } + return feature.sessions.first { $0.id == id }.map(sessionTitle) ?? String(localized: "Untitled conversation") + } + + private func sessionTitle(_ session: AgentSessionSummary) -> String { + AgentSessionTitle.title(of: AgentSessionSummary(id: session.id, title: history.title(for: session))) + } +} + +struct AgentSessionTabItem: Identifiable, Equatable { + let id: String + let title: String + let isSelected: Bool + let isBusy: Bool + let needsAttention: Bool +} + +/// Agent badge on the left, then one tab per open conversation and a "new" button. +/// One tab per open conversation and a "new" button. +struct AgentSessionTabStrip: View { + let tabs: [AgentSessionTabItem] + let showsNewTab: Bool + let isNewTabBusy: Bool + var newTabTitle: String? = nil + let onSelect: (String) -> Void + let onClose: (String) -> Void + let onNew: () -> Void + + var body: some View { + HStack(spacing: 4) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 2) { + ForEach(tabs) { tab in + AgentSessionTab( + title: tab.title, + isSelected: tab.isSelected, + isBusy: tab.isBusy, + needsAttention: tab.needsAttention, + select: { onSelect(tab.id) }, + close: { onClose(tab.id) } + ) + } + if showsNewTab { + AgentSessionTab( + title: newTabTitle ?? String(localized: "New conversation"), + isSelected: true, + isBusy: isNewTabBusy, + needsAttention: false, + select: {}, + close: nil + ) + } + } + } + Button(action: onNew) { + Image(systemName: "plus") + .font(.system(size: 11, weight: .semibold)) + } + .litheIconButton() + .help("New conversation") + .disabled(showsNewTab) + } + .padding(.horizontal, 8) + .frame(height: 32) + .background(LitheTheme.toolHeaderInactive) + } +} + +private struct AgentSessionTab: View { + let title: String + let isSelected: Bool + let isBusy: Bool + let needsAttention: Bool + let select: () -> Void + let close: (() -> Void)? + @State private var isHovering = false + + var body: some View { + HStack(spacing: 5) { + if isBusy { + ProgressView().controlSize(.mini) + } else if needsAttention { + Circle().fill(LitheTheme.warning).frame(width: 6, height: 6) + } + Text(title) + .font(.system(size: 12, weight: isSelected ? .medium : .regular)) + .lineLimit(1) + .frame(maxWidth: 140) + if let close, isHovering || isSelected { + Button(action: close) { + Image(systemName: "xmark") + .font(.system(size: 9, weight: .bold)) + .frame(width: 14, height: 14) + } + .buttonStyle(.plain) + .lithePointer() + .foregroundStyle(LitheTheme.tertiaryText) + .help("Close conversation") + } + } + .padding(.horizontal, 8) + .padding(.vertical, 5) + .foregroundStyle(isSelected ? LitheTheme.primaryText : LitheTheme.secondaryText) + .background( + RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius) + .fill(isSelected ? LitheTheme.activeTabBackground : (isHovering ? LitheTheme.hoverBackground : .clear)) + ) + .contentShape(Rectangle()) + .onTapGesture(perform: select) + .onHover { isHovering = $0 } + } +} + +struct AgentEmptyStateView: View { + let systemImage: String + let title: LocalizedStringKey + /// Already localized; callers format dynamic values into it. + let message: String + var actionTitle: LocalizedStringKey? = nil + var action: (() -> Void)? = nil + var secondaryActionTitle: LocalizedStringKey? = nil + var secondaryAction: (() -> Void)? = nil + var isBusy = false + + var body: some View { + VStack(spacing: 10) { + if isBusy { + ProgressView().controlSize(.regular) + } else { + Image(systemName: systemImage) + .font(.system(size: 28, weight: .light)) + .foregroundStyle(LitheTheme.tertiaryText) + } + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + Text(message) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .multilineTextAlignment(.center) + .textSelection(.enabled) + HStack(spacing: 8) { + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 12, height: 26, fontSize: 12)) + } + if let secondaryActionTitle, let secondaryAction { + Button(secondaryActionTitle, action: secondaryAction) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 12, height: 26, fontSize: 12)) + } + } + .padding(.top, 4) + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +struct AgentInlineNotice: View { + let text: String + var actionTitle: LocalizedStringKey? = nil + var action: (() -> Void)? = nil + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Image(systemName: "exclamationmark.circle.fill") + .foregroundStyle(LitheTheme.warning) + Text(text) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if let actionTitle, let action { + Button(actionTitle, action: action) + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + } + } + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.primaryText) + .padding(10) + .background(LitheTheme.warning.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.warning.opacity(0.4), lineWidth: 1)) + .padding(.horizontal, 12) + .padding(.bottom, 4) + } +} + +enum AgentSessionTitle { + static func title(of session: AgentSessionSummary) -> String { + guard let title = session.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty else { + return String(localized: "Untitled conversation") + } + return title + } + + static func provisional(_ prompt: String) -> String { + let line = prompt.split(whereSeparator: \.isNewline).first.map(String.init) ?? prompt + return line.count > 40 ? String(line.prefix(40)) + "…" : line + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift new file mode 100644 index 000000000..4eaa5e1f9 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift @@ -0,0 +1,34 @@ +import SwiftUI +import LitheAgentConversationModule + +struct AgentFileReferenceList: View { + let files: [AgentFileReference] + let onRemove: (String) -> Void + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 6) { + ForEach(files) { file in + HStack(spacing: 5) { + Image(systemName: "doc") + Text(file.name).lineLimit(1).truncationMode(.middle).frame(maxWidth: 160) + Button { onRemove(file.id) } label: { + Image(systemName: "xmark").font(.system(size: 9)) + } + .buttonStyle(.plain) + .accessibilityLabel(String(format: String(localized: "Remove file %@"), file.name)) + } + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.text) + .padding(.horizontal, 7) + .padding(.vertical, 5) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 5)) + .help(file.url.path) + } + } + .padding(.horizontal, 8) + } + .scrollIndicators(.hidden) + .frame(height: 34) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift new file mode 100644 index 000000000..7e1ec1549 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift @@ -0,0 +1,370 @@ +import SwiftUI +import LitheAgentConversationModule +import LitheCoreContracts + +enum AgentHistoryFilter: String, CaseIterable { + case all, favorites, removed + var title: LocalizedStringKey { + switch self { + case .all: "All conversations" + case .favorites: "Favorites" + case .removed: "Removed conversations" + } + } +} + +enum AgentHistoryPresentation { + static func sessions(_ sessions: [AgentSessionSummary], metadata: [String: AgentHistoryMetadata], + query: String, filter: AgentHistoryFilter) -> [AgentSessionSummary] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + return sessions.filter { session in + let entry = metadata[session.id] ?? AgentHistoryMetadata() + guard entry.isHidden == (filter == .removed), filter != .favorites || entry.isFavorite else { return false } + let title = entry.title ?? session.title ?? "" + return query.isEmpty || title.localizedStandardContains(query) || session.id.localizedStandardContains(query) + }.map { (session: $0, date: date($0.updatedAt)) }.sorted { lhs, rhs in + if lhs.date != rhs.date { return (lhs.date ?? .distantPast) > (rhs.date ?? .distantPast) } + return lhs.session.id < rhs.session.id + }.map(\.session) + } + + static func date(_ value: String?) -> Date? { + guard let value else { return nil } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: value) { return date } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } + + static func messageCount(_ conversation: AgentConversation?) -> Int? { + guard let conversation, !conversation.isLoading, + conversation.isAttached || !conversation.messages.isEmpty else { return nil } + return conversation.messages.filter { $0.role != .tool }.count + } +} + +/// In-panel history page: annotations stay in Lithe, transcripts stay with the Agent. +struct AgentHistoryView: View { + @ObservedObject var feature: AgentConnectionModel + @ObservedObject var history: AgentHistoryFeatureModel + let agentName: String? + let onBack: () -> Void + let onCopySessionID: (String) -> Void + let onSelect: (String) -> Void + let onReconnect: () -> Void + @State private var query = "" + @State private var filter = AgentHistoryFilter.all + @State private var isSelecting = false + @State private var selectedIDs: Set = [] + @State private var editingID: String? + @State private var editingTitle = "" + @State private var copiedID: String? + @State private var pendingRemovalIDs: [String]? + + private var sessions: [AgentSessionSummary] { + AgentHistoryPresentation.sessions(feature.sessions, metadata: history.metadata, query: query, filter: filter) + } + private var visibleIDs: [String] { sessions.map(\.id) } + private var selected: [String] { visibleIDs.filter(selectedIDs.contains) } + + var body: some View { + VStack(spacing: 0) { + HStack { + Button(action: onBack) { Label("Back", systemImage: "arrow.left") } + .buttonStyle(.plain).lithePointer() + .accessibilityIdentifier("agent-history-back") + Spacer() + Text("Conversation history").fontWeight(.medium) + } + .foregroundStyle(AgentPanelStyle.secondary) + .font(.system(size: 12)) + .padding(.horizontal, 20) + .frame(height: 44) + .background(AgentPanelStyle.header) + divider + toolbar + divider + if let error = history.errorMessage ?? feature.historyError { + AgentInlineNotice(text: error) + .padding(.top, 8) + } + if case .failed(let message) = feature.connectionState { + AgentInlineNotice(text: message, actionTitle: "Reconnect", action: onReconnect) + .padding(.top, 8) + } + if history.isExporting { + HStack { + ProgressView().controlSize(.mini) + Text("Exporting conversations…") + Spacer() + Button("Cancel") { history.cancelExport() }.buttonStyle(.plain) + }.font(.system(size: 12)).padding(12) + } + if sessions.isEmpty { + emptyState + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(sessions) { session in row(session) } + } + } + } + } + .background(AgentPanelStyle.canvas) + .onChange(of: visibleIDs) { selectedIDs.formIntersection($0) } + .alert("Confirm deletion", isPresented: Binding( + get: { pendingRemovalIDs != nil }, + set: { if !$0 { pendingRemovalIDs = nil } } + ), presenting: pendingRemovalIDs) { ids in + Button("Cancel", role: .cancel) { pendingRemovalIDs = nil } + Button("Delete", role: .destructive) { + if history.setHidden(ids, true) { selectedIDs.subtract(ids) } + } + } message: { ids in + Text(String(format: String(localized: "Remove %d conversation(s) from history? You can restore them from Removed conversations. The Agent's original records will be kept."), ids.count)) + } + .onDisappear { pendingRemovalIDs = nil } + .accessibilityIdentifier("agent-history-page") + } + + private var toolbar: some View { + VStack(spacing: 10) { + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + summaryControls.fixedSize(horizontal: true, vertical: false) + searchControls.frame(minWidth: 180, maxWidth: 280) + } + VStack(spacing: 10) { + summaryControls + searchControls + } + } + if isSelecting { + HStack(spacing: 8) { + Button(selected.count == sessions.count && !sessions.isEmpty ? "Deselect all" : "Select all") { + selectedIDs = selected.count == sessions.count ? [] : Set(visibleIDs) + }.disabled(sessions.isEmpty) + Spacer(minLength: 0) + Menu { + Button("Export conversations") { history.export(selected) } + .disabled(!history.canExport(selected)) + Button("Add to favorites") { history.setFavorite(selected, true) } + Button("Remove from favorites") { history.setFavorite(selected, false) } + Divider() + Button(filter == .removed ? "Restore conversations" : "Remove from history") { + if filter == .removed { + if history.setHidden(selected, false) { selectedIDs = [] } + } else { + pendingRemovalIDs = selected + } + } + } label: { Text("Manage selected") } + .disabled(selected.isEmpty) + }.font(.system(size: 11)).buttonStyle(.borderless) + } + if filter == .removed { + Text("Removed conversations are hidden only in Lithe. The Agent's original records are kept.") + .font(.system(size: 11)).foregroundStyle(AgentPanelStyle.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 20).padding(.vertical, 14) + } + + private var summaryControls: some View { + HStack(spacing: 8) { + Text(isSelecting + ? String(format: String(localized: "%d selected"), selected.count) + : String(format: String(localized: "%d conversations"), sessions.count)) + .font(.system(size: 12)).foregroundStyle(AgentPanelStyle.secondary) + Spacer(minLength: 0) + Button { + isSelecting.toggle(); selectedIDs = []; editingID = nil + } label: { + Label(isSelecting ? "Done" : "Select", systemImage: "checklist") + .font(.system(size: 11)) + }.buttonStyle(.bordered).controlSize(.small) + .accessibilityIdentifier("agent-history-select") + Button { feature.refreshSessions() } label: { + if feature.isRefreshingSessions { ProgressView().controlSize(.mini) } + else { Image(systemName: "arrow.clockwise") } + }.buttonStyle(.bordered).controlSize(.small) + .disabled(!feature.canRefreshSessions || feature.isRefreshingSessions) + .help("Refresh history") + .accessibilityIdentifier("agent-history-refresh") + } + } + + private var searchControls: some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + TextField("Search conversation titles or IDs…", text: $query) + .textFieldStyle(.plain) + .accessibilityIdentifier("agent-history-search") + if !query.isEmpty { + Button { query = "" } label: { Image(systemName: "xmark") } + .buttonStyle(.plain).help("Clear search") + } else { Image(systemName: "magnifyingglass") } + } + .font(.system(size: 12)) + .padding(.horizontal, 10).frame(height: 28) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(AgentPanelStyle.border)) + Menu { + Picker("History filter", selection: $filter) { + ForEach(AgentHistoryFilter.allCases, id: \.self) { Text($0.title).tag($0) } + } + } label: { Image(systemName: filter == .favorites ? "star.fill" : "line.3.horizontal.decrease") } + .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + .frame(width: 24).help(filter.title) + .accessibilityIdentifier("agent-history-filter") + }.foregroundStyle(AgentPanelStyle.secondary) + } + + @ViewBuilder private var emptyState: some View { + if feature.isRefreshingSessions { + AgentEmptyStateView(systemImage: "clock", title: "Loading history…", message: "", isBusy: true) + } else if !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + AgentEmptyStateView(systemImage: "magnifyingglass", title: "No matching conversations", + message: String(localized: "Try another title or session ID.")) + } else { + AgentEmptyStateView(systemImage: "clock.arrow.circlepath", title: "No earlier conversations", + message: String(localized: "Conversations for this project and Agent appear here.")) + } + } + + private func row(_ session: AgentSessionSummary) -> some View { + AgentHistoryRow( + session: session, title: displayTitle(session), agentName: agentName, + messageCount: AgentHistoryPresentation.messageCount(feature.conversations[session.id]), + isCurrent: session.id == feature.selectedSessionID, + isFavorite: history.metadata[session.id]?.isFavorite == true, + isRemoved: filter == .removed, isSelecting: isSelecting, + isSelected: selectedIDs.contains(session.id), isEditing: editingID == session.id, + editingTitle: $editingTitle, isCopied: copiedID == session.id, + canExport: history.canExport([session.id]), + canOpen: feature.canLoadSessions && feature.connectionState == .ready + || feature.conversations[session.id]?.isAttached == true, + onOpen: { onSelect(session.id) }, + onToggleSelection: { + if !selectedIDs.insert(session.id).inserted { selectedIDs.remove(session.id) } + }, + onCopy: { onCopySessionID(session.id); copiedID = session.id }, + onRename: { editingTitle = displayTitle(session); editingID = session.id }, + onSave: { if history.rename(session.id, title: editingTitle) { editingID = nil } }, + onCancel: { editingID = nil }, + onExport: { history.export([session.id]) }, + onFavorite: { history.setFavorite([session.id], history.metadata[session.id]?.isFavorite != true) }, + onRemove: { + if filter == .removed { history.setHidden([session.id], false) } + else { pendingRemovalIDs = [session.id] } + } + ) + } + + private func displayTitle(_ session: AgentSessionSummary) -> String { + AgentSessionTitle.title(of: AgentSessionSummary(id: session.id, title: history.title(for: session))) + } + + private var divider: some View { Rectangle().fill(AgentPanelStyle.border).frame(height: 1) } +} + +private struct AgentHistoryRow: View { + let session: AgentSessionSummary + let title: String + let agentName: String? + let messageCount: Int? + let isCurrent: Bool + let isFavorite: Bool + let isRemoved: Bool + let isSelecting: Bool + let isSelected: Bool + let isEditing: Bool + @Binding var editingTitle: String + let isCopied: Bool + let canExport: Bool + let canOpen: Bool + let onOpen: () -> Void + let onToggleSelection: () -> Void + let onCopy: () -> Void + let onRename: () -> Void + let onSave: () -> Void + let onCancel: () -> Void + let onExport: () -> Void + let onFavorite: () -> Void + let onRemove: () -> Void + @State private var isHovering = false + @FocusState private var isTitleFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + if isSelecting { + Button(action: onToggleSelection) { + Image(systemName: isSelected ? "checkmark.square.fill" : "square") + }.buttonStyle(.plain).help("Select conversation") + } + AgentBrandIcon(name: agentName, size: 18).foregroundStyle(AgentPanelStyle.text) + if isEditing { + TextField("Conversation title", text: $editingTitle) + .textFieldStyle(.plain).focused($isTitleFocused) + .onSubmit(onSave).onExitCommand(perform: onCancel) + .onAppear { isTitleFocused = true } + icon("checkmark", "Save title", onSave) + .disabled(editingTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + icon("xmark", "Cancel", onCancel) + } else { + Button(action: isSelecting ? onToggleSelection : onOpen) { + Text(title).font(.system(size: 14, weight: .semibold)) + .lineLimit(1).frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + }.buttonStyle(.plain).lithePointer() + .disabled(!isSelecting && !canOpen).help(title) + if let date = AgentHistoryPresentation.date(session.updatedAt) { + Text(date, format: .relative(presentation: .named, unitsStyle: .abbreviated)) + .font(.system(size: 11)).foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1).fixedSize() + .help(date.formatted(date: .abbreviated, time: .shortened)) + } + } + } + HStack(spacing: 6) { + if let messageCount { + Text(String(format: String(localized: "%d messages"), messageCount)) + .lineLimit(1) + Text("·") + } + Text(String(session.id.prefix(8))).font(.system(size: 10, design: .monospaced)) + .help(session.id) + icon(isCopied ? "checkmark" : "doc.on.doc", "Copy session ID", onCopy) + .accessibilityIdentifier("agent-history-copy-\(session.id)") + Spacer(minLength: 0) + if !isSelecting && !isEditing { + HStack(spacing: 2) { + if !isRemoved { + icon("pencil", "Rename conversation", onRename) + icon("arrow.down", "Export conversations", onExport).disabled(!canExport) + } + icon(isRemoved ? "arrow.uturn.backward" : "trash", isRemoved ? "Restore conversations" : "Remove from history", onRemove) + icon(isFavorite ? "star.fill" : "star", "Toggle favorite", onFavorite) + .foregroundStyle(isFavorite ? LitheTheme.warning : AgentPanelStyle.secondary) + } + .opacity(isHovering || isFavorite || isCurrent ? 1 : 0) + .accessibilityElement(children: .contain) + } + } + .font(.system(size: 11)).foregroundStyle(AgentPanelStyle.secondary) + } + .foregroundStyle(AgentPanelStyle.text) + .padding(.horizontal, 20).padding(.vertical, 14) + .background(isSelected ? AgentPanelStyle.selected : (isHovering || isCurrent ? AgentPanelStyle.header : .clear)) + .overlay(alignment: .bottom) { Rectangle().fill(AgentPanelStyle.border).frame(height: 1) } + .onHover { isHovering = $0 } + .accessibilityIdentifier("agent-history-row-\(session.id)") + } + + private func icon(_ name: String, _ title: LocalizedStringKey, _ action: @escaping () -> Void) -> some View { + Button(action: action) { Image(systemName: name).font(.system(size: 11)).frame(width: 22, height: 22) } + .buttonStyle(.plain).lithePointer().help(title).accessibilityLabel(title) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift new file mode 100644 index 000000000..33f2d1969 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift @@ -0,0 +1,672 @@ +import AppKit +import SwiftUI +import LitheCoreContracts + +/// Settings shown inside the Agent panel: an agent list on the left and one +/// agent's detail on the right, in the style of Codeg's "Agent SDK +/// Management". Each agent can follow local CLI configuration or a custom provider for its +/// endpoint, model and API key. Node.js remains user-managed; CLI updates use +/// their installation owner, while ACP adapters are installed with npm. +struct AgentPanelSettingsView: View { + @ObservedObject var model: AppModel + @ObservedObject var settings: AppSettings + @ObservedObject var feature: AgentManagementFeatureModel + let onDone: () -> Void + @State private var section: AgentSettingsSection = .agents + + /// Pages of the panel settings. Only Agents exists today; the rail keeps + /// room for more without changing the layout. + enum AgentSettingsSection: CaseIterable, Hashable { + case agents + + var title: LocalizedStringKey { + switch self { + case .agents: "Agents" + } + } + + var subtitle: LocalizedStringKey { + switch self { + case .agents: "Install ACP adapters, check the runtime, and manage local or custom providers." + } + } + + var systemImage: String { + switch self { + case .agents: "sparkles" + } + } + } + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 8) { + Button(action: onDone) { + Image(systemName: "chevron.left") + .font(.system(size: 11, weight: .semibold)) + } + .litheIconButton() + .help("Back to the conversation") + Text("Settings").font(.system(size: 13, weight: .semibold)) + Spacer() + } + .padding(.horizontal, 8) + .frame(height: 34) + .background(LitheTheme.toolHeaderInactive) + Divider().overlay(LitheTheme.divider) + HStack(spacing: 0) { + rail + Divider().overlay(LitheTheme.divider) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 3) { + Text(section.title).font(.system(size: 16, weight: .semibold)) + Text(section.subtitle) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + switch section { + case .agents: + AgentsManagementPage(model: model, settings: settings, feature: feature) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .task { await feature.refresh() } + } + + private var rail: some View { + VStack(spacing: 4) { + ForEach(AgentSettingsSection.allCases, id: \.self) { item in + Button { + section = item + } label: { + Image(systemName: item.systemImage) + .font(.system(size: 14, weight: .medium)) + .frame(width: 32, height: 32) + .foregroundStyle(section == item ? Color.white : LitheTheme.secondaryText) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(section == item ? LitheTheme.accent : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .help(item.title) + } + Spacer() + } + .padding(6) + .frame(width: 44) + .background(LitheTheme.sidebar) + } +} + +// MARK: - Agents page + +/// Agent list on the left with a status badge per agent, selected agent's +/// detail on the right: header with enable switch, preflight checklist, +/// provider picker. The feature toggle and custom agent are list entries too. +private struct AgentsManagementPage: View { + @ObservedObject var model: AppModel + @ObservedObject var settings: AppSettings + @ObservedObject var feature: AgentManagementFeatureModel + @State private var selectedID: String? + + private enum Entry: Hashable { + case agent(String) + case custom + } + + private var agents: [AgentCatalogStatus] { feature.status?.agents ?? [] } + + private var selectedEntry: Entry { + if let selectedID, agents.contains(where: { $0.id == selectedID }) { return .agent(selectedID) } + if selectedID == AgentConfiguration.customAgentID { return .custom } + return agents.first.map { .agent($0.id) } ?? .custom + } + + var body: some View { + featureRow + segments + switch selectedEntry { + case .agent(let id): + if let status = feature.status, let agent = agents.first(where: { $0.id == id }) { + AgentDetailView( + agent: agent, + environment: status.environment, + model: model, + feature: feature, + settings: settings + ) + } + case .custom: + CustomAgentDetailView(model: model, settings: settings) + } + } + + private var featureRow: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text("Enable Agent conversation") + .font(.system(size: 12.5, weight: .medium)) + Text("Turning this off stops every Agent process.") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + Toggle("", isOn: Binding( + get: { model.isAgentConversationEnabled }, + set: { enabled in Task { await model.setAgentConversationEnabled(enabled) } } + )) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + } + .padding(12) + .background(LitheTheme.raised, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.panelBorder, lineWidth: 1)) + } + + /// One segment per agent, with the preflight summary as a coloured dot. + private var segments: some View { + HStack(spacing: 2) { + if feature.status == nil { + HStack(spacing: 6) { + ProgressView().controlSize(.mini) + Text("Checking…").font(.system(size: 11.5)).foregroundStyle(LitheTheme.secondaryText) + } + .frame(maxWidth: .infinity) + .frame(height: 30) + } + ForEach(agents) { agent in + segment( + title: agent.name, + systemImage: "cpu", + dot: summaryColor(for: agent), + isSelected: selectedEntry == .agent(agent.id), + isBusy: feature.busyAgentID == agent.id + ) { selectedID = agent.id } + } + segment( + title: String(localized: "Custom Agent"), + systemImage: "terminal", + dot: settings.agentConfigurations[AgentConfiguration.customAgentID]?.providerID != nil ? LitheTheme.success : nil, + isSelected: selectedEntry == .custom, + isBusy: false + ) { selectedID = AgentConfiguration.customAgentID } + Button { + Task { await feature.refresh() } + } label: { + Image(systemName: "arrow.clockwise") + .font(.system(size: 11, weight: .medium)) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .foregroundStyle(LitheTheme.secondaryText) + .disabled(feature.phase == .checking) + .help("Check Again") + } + .padding(3) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.panelBorder, lineWidth: 1)) + } + + private func segment( + title: String, systemImage: String, dot: Color?, isSelected: Bool, isBusy: Bool, select: @escaping () -> Void + ) -> some View { + Button(action: select) { + HStack(spacing: 6) { + Image(systemName: systemImage).font(.system(size: 11)) + Text(title).font(.system(size: 12, weight: isSelected ? .medium : .regular)).lineLimit(1) + if isBusy { + ProgressView().controlSize(.mini) + } else if let dot { + Circle().fill(dot).frame(width: 6, height: 6) + } + } + .foregroundStyle(isSelected ? Color.white : LitheTheme.secondaryText) + .frame(maxWidth: .infinity) + .frame(height: 30) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? LitheTheme.accent : .clear) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + } + + private func summaryColor(for agent: AgentCatalogStatus) -> Color? { + guard let environment = feature.status?.environment else { return nil } + let checks = AgentSetupDiagnostics.preflight( + for: agent, + environment: environment, + hasProvider: settings.agentConfigurations[agent.id]?.providerID != nil + ) + switch AgentSetupDiagnostics.summary(of: checks) { + case .pass: return LitheTheme.success + case .warn: return LitheTheme.warning + case .fail: return LitheTheme.error + case nil: return nil + } + } +} + +// MARK: - Agent detail + +private struct AgentDetailView: View { + let agent: AgentCatalogStatus + let environment: AgentRuntimeEnvironment + @ObservedObject var model: AppModel + @ObservedObject var feature: AgentManagementFeatureModel + @ObservedObject var settings: AppSettings + @State private var expanded: Set = [] + + private var isBusy: Bool { feature.busyAgentID == agent.id } + private var hasProvider: Bool { settings.agentConfigurations[agent.id]?.providerID != nil } + private var checks: [AgentPreflightCheck] { + AgentSetupDiagnostics.preflight(for: agent, environment: environment, hasProvider: hasProvider) + } + + var body: some View { + header + if isBusy { + AgentInstallProgressView(feature: feature) + } + if let error = feature.errors[agent.id] { + AgentSettingsIssue(error) + } + if let result = feature.cliUpdates[agent.id] { + AgentCliUpdateOutcomeView(result: result) + } + versionCard + preflightCard + AgentProviderConfigurationView( + agentID: agent.id, + name: agent.name, + source: AppModel.localConfigurationSource(for: agent.id), + model: model, + settings: settings + ) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "cpu") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(LitheTheme.accent) + Text(agent.name).font(.system(size: 14, weight: .semibold)) + AgentBadge(text: "ACP adapter", color: LitheTheme.secondaryText) + .help("Lithe installs the ACP adapter package, not the vendor CLI. The two are independent and share the CLI's configuration.") + if !agent.verified { + AgentBadge(text: "Preview", color: LitheTheme.warning) + .help("Lithe has not yet completed a real conversation with this adapter version.") + } + Spacer() + } + if let cli = agent.cli { + Text(String(format: String(localized: "Runs the %@ installed on this Mac through the %@ adapter."), cli.name, agent.package)) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var versionCard: some View { + AgentSettingsCard(title: "Version status", systemImage: "shippingbox") { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 3) { + Text(String(format: String(localized: "Pinned: %@ · Local: %@"), + agent.version, agent.installedVersion ?? String(localized: "Not installed"))) + .font(.system(size: 12, design: .monospaced)) + Text(versionHint) + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + } + Spacer() + if !isBusy { + if !agent.isInstalled || agent.needsUpdate { + Button(agent.isInstalled ? "Update" : "Install") { feature.install(agent.id) } + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.accent, restingOpacity: 1)) + .controlSize(.small) + .disabled(!AgentSetupDiagnostics.canInstallAdapter(checks) || feature.busyAgentID != nil) + } + if agent.isInstalled { + Button("Uninstall") { feature.uninstall(agent.id) } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + .disabled(feature.busyAgentID != nil) + } + } + } + Text(agent.package) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(LitheTheme.tertiaryText) + } + } + + private var versionHint: String { + if !agent.isInstalled { + return AgentSetupDiagnostics.canInstallAdapter(checks) + ? String(localized: "Not installed. Install it to start using this Agent.") + : String(localized: "Install Node.js and npm before installing the adapter.") + } + return agent.needsUpdate + ? String(localized: "An update is available.") + : String(localized: "Up to date.") + } + + private var preflightCard: some View { + AgentSettingsCard( + title: "Preflight", + systemImage: "checklist", + badge: summaryBadge + ) { + VStack(spacing: 4) { + ForEach(checks) { check in + preflightRow(check) + } + } + } + } + + private var summaryBadge: (LocalizedStringKey, Color)? { + switch AgentSetupDiagnostics.summary(of: checks) { + case .pass: ("PASS", LitheTheme.success) + case .warn: ("WARN", LitheTheme.warning) + case .fail: ("FAIL", LitheTheme.error) + case nil: nil + } + } + + private func preflightRow(_ check: AgentPreflightCheck) -> some View { + let isExpanded = expanded.contains(check.id) || check.status != .pass + return VStack(alignment: .leading, spacing: 6) { + Button { + if expanded.contains(check.id) { expanded.remove(check.id) } else { expanded.insert(check.id) } + } label: { + HStack(spacing: 6) { + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(LitheTheme.tertiaryText) + .frame(width: 10) + Text(check.title) + .font(.system(size: 12, weight: .medium)) + .lineLimit(1) + Spacer() + AgentStatusBadge(status: check.status) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + if isExpanded { + HStack(alignment: .top, spacing: 8) { + Text(check.message) + .font(.system(size: 11, design: check.status == .pass ? .monospaced : .default)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + .lineLimit(check.id == "cli" ? nil : 3) + .truncationMode(.middle) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + if let fix = check.fix, !isBusy { + fixButton(fix) + } + } + .padding(.leading, 16) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(LitheTheme.inputBackground.opacity(0.6), in: RoundedRectangle(cornerRadius: 6)) + } + + @ViewBuilder + private func fixButton(_ fix: AgentPreflightCheck.Fix) -> some View { + switch fix { + case .install: + Button("Install") { feature.install(agent.id) } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 8, height: 22, fontSize: 11)) + .disabled(feature.busyAgentID != nil) + case .update: + Button("Update") { feature.install(agent.id) } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 8, height: 22, fontSize: 11)) + .disabled(feature.busyAgentID != nil) + case .installCli: + Button("Install") { feature.installCli(agent.id) } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 8, height: 22, fontSize: 11)) + .disabled(feature.busyAgentID != nil) + .help(agent.cli?.installation?.updateHint ?? agent.cli?.installHint ?? "") + case .updateCli: + Button("Update") { feature.installCli(agent.id) } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 8, height: 22, fontSize: 11)) + .disabled(feature.busyAgentID != nil) + .help(agent.cli?.installation?.updateHint ?? agent.cli?.installHint ?? "") + case .fetchLocalConfiguration: + if let source = AppModel.localConfigurationSource(for: agent.id) { + Button("Fetch local configuration") { + model.importLocalConfiguration(for: agent.id, source: source, name: agent.name) + } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 8, height: 22, fontSize: 11)) + } + } + } +} + +private struct AgentStatusBadge: View { + let status: AgentPreflightCheck.Status + + var body: some View { + let (text, color): (String, Color) = switch status { + case .pass: ("PASS", LitheTheme.success) + case .warn: ("WARN", LitheTheme.warning) + case .fail: ("FAIL", LitheTheme.error) + } + Text(text) + .font(.system(size: 9.5, weight: .bold)) + .foregroundStyle(color) + } +} + +struct AgentBadge: View { + let text: LocalizedStringKey + let color: Color + + var body: some View { + Text(text) + .font(.system(size: 10, weight: .semibold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .foregroundStyle(color) + .background(color.opacity(0.15), in: Capsule()) + } +} + +// MARK: - Custom agent detail + +private struct CustomAgentDetailView: View { + @ObservedObject var model: AppModel + @ObservedObject var settings: AppSettings + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "terminal") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(LitheTheme.accent) + Text("Custom Agent").font(.system(size: 14, weight: .semibold)) + AgentBadge(text: "Custom", color: LitheTheme.secondaryText) + Spacer() + } + AgentSettingsHint("Run another ACP Agent you installed yourself. It must support signing in with a custom API key gateway.") + AgentSettingsCard(title: "Launch", systemImage: "play") { + VStack(alignment: .leading, spacing: 4) { + Text("Agent executable") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + TextField("Agent executable", text: $settings.agentCommand) + .litheSettingsTextField() + } + VStack(alignment: .leading, spacing: 4) { + Text("Arguments (one per line)") + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + TextEditor(text: $settings.agentArguments) + .font(.system(size: 12, design: .monospaced)) + .scrollContentBackground(.hidden) + .padding(4) + .frame(height: 52) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .overlay(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius).stroke(LitheTheme.inputBorder, lineWidth: 1)) + } + } + AgentProviderConfigurationView( + agentID: AgentConfiguration.customAgentID, + name: String(localized: "Custom Agent"), + source: nil, + model: model, + settings: settings + ) + } +} + +// MARK: - Shared pieces + +struct AgentSettingsCard: View { + let title: LocalizedStringKey + let systemImage: String + var badge: (LocalizedStringKey, Color)? = nil + @ViewBuilder let content: Content + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.accent) + Text(title).font(.system(size: 13, weight: .semibold)) + if let (text, color) = badge { + AgentBadge(text: text, color: color) + } + Spacer() + } + content + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.raised, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(LitheTheme.panelBorder, lineWidth: 1)) + } +} + +struct AgentSettingsHint: View { + let text: LocalizedStringKey + init(_ text: LocalizedStringKey) { self.text = text } + + var body: some View { + Text(text) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } +} + +private struct AgentCliUpdateOutcomeView: View { + let result: AgentCliUpdateResult + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Label(String(format: String(localized: "CLI updated to %@"), result.cliVersion), + systemImage: "checkmark.circle.fill") + .foregroundStyle(LitheTheme.success) + if let warning = result.updaterWarning { + Text("The installer reported an error, but the new CLI version was verified.") + .foregroundStyle(LitheTheme.warning) + DisclosureGroup("Update log") { + Text(warning).textSelection(.enabled) + .foregroundStyle(LitheTheme.secondaryText) + } + } + } + .font(LitheTheme.smallFont) + .fixedSize(horizontal: false, vertical: true) + } +} + +struct AgentSettingsIssue: View { + let text: String + init(_ text: String) { self.text = text } + + var body: some View { + Label { + Text(text).textSelection(.enabled) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.warning) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// The download's total size is owned by npm and not known in advance. +/// Report actual received bytes and rate instead of inventing an install percentage. +private struct AgentInstallProgressView: View { + @ObservedObject var feature: AgentManagementFeatureModel + + var body: some View { + TimelineView(.periodic(from: feature.operationStartedAt ?? Date(), by: 1)) { timeline in + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(title).font(.system(size: 12)) + Spacer() + Button("Cancel") { feature.cancelOperation() } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + .disabled(feature.isCancelling) + } + if let progress = feature.installProgress, progress.downloadedBytes > 0 { + Text(String(format: String(localized: "Downloaded %@ · %@/s"), + bytes(progress.downloadedBytes), bytes(progress.bytesPerSecond))) + .monospacedDigit() + } + let elapsed = max(0, Int(timeline.date.timeIntervalSince(feature.operationStartedAt ?? timeline.date))) + Text(String(format: String(localized: "Elapsed %lld s"), elapsed)) + .monospacedDigit() + if let progress = feature.installProgress, progress.stage == .downloading, + progress.idleMilliseconds >= 15_000, !feature.isCancelling { + Text("Waiting for download data. Check your network or proxy if this continues.") + .foregroundStyle(LitheTheme.warning) + } + } + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.secondaryText) + .padding(10) + .background(LitheTheme.accent.opacity(0.1), in: RoundedRectangle(cornerRadius: 8)) + } + } + + private var title: String { + if feature.isCancelling { return String(localized: "Stopping installation…") } + switch feature.installProgress?.stage { + case .preparing: return String(localized: "Preparing download…") + case .downloading: return String(localized: "Downloading packages…") + case .installing: return String(localized: "Installing packages…") + case .updating: return String(localized: "Updating CLI…") + case nil: return String(localized: "Working with npm…") + } + } + + private func bytes(_ value: UInt64) -> String { + ByteCountFormatter.string(fromByteCount: Int64(clamping: value), countStyle: .file) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift new file mode 100644 index 000000000..e5a46cf66 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentPanelStyle.swift @@ -0,0 +1,98 @@ +import AppKit +import SwiftUI + +/// Conversation-local colors mirror the reference without changing the workbench. +enum AgentPanelStyle { + static let canvas = adaptive(dark: 0x1e1e1e, light: 0xffffff) + static let header = adaptive(dark: 0x242424, light: 0xf5f5f5) + static let context = adaptive(dark: 0x2f2f2f, light: 0xf0f0f0) + static let toolbar = adaptive(dark: 0x1b1b1b, light: 0xf8f8f8) + static let border = adaptive(dark: 0x303030, light: 0xdcdcdc) + static let text = adaptive(dark: 0xcccccc, light: 0x333333) + static let secondary = adaptive(dark: 0x888888, light: 0x666666) + static let muted = adaptive(dark: 0x666666, light: 0x777777) + static let logo = adaptive(dark: 0x555555, light: 0x777777) + static let focus = adaptive(dark: 0x007fd4, light: 0x0078d4) + static let selected = adaptive(dark: 0x094771, light: 0xcce7ff) + static let versionText = adaptive(dark: 0xddd6fe, light: 0x6d28d9) + static let versionAccent = Color(red: 139 / 255, green: 92 / 255, blue: 246 / 255) + + private static func adaptive(dark: UInt32, light: UInt32) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let hex = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua ? dark : light + return NSColor( + srgbRed: CGFloat((hex >> 16) & 255) / 255, + green: CGFloat((hex >> 8) & 255) / 255, + blue: CGFloat(hex & 255) / 255, + alpha: 1 + ) + }) + } +} + +/// Template SVGs use the same vendor silhouettes at welcome and toolbar sizes. +struct AgentBrandIcon: View { + let name: String? + var size: CGFloat = 16 + + var body: some View { + Group { + if let image = AgentBrandIconLoader.image(name: name, size: size) { + Image(nsImage: image) + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + } else { + Image(systemName: "sparkles") + .resizable() + .aspectRatio(contentMode: .fit) + } + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +@MainActor +enum AgentBrandIconLoader { + private struct CacheKey: Hashable { + let filename: String + let size: Int + } + private static var images: [CacheKey: NSImage] = [:] + + static func image(name: String?, size: CGFloat = 64) -> NSImage? { + let filename: String + switch name?.lowercased() { + case "codex": filename = "openai" + case "claude", "claude code": filename = "claude" + default: return nil + } + let key = CacheKey(filename: filename, size: max(1, Int(size.rounded()))) + if let image = images[key] { return image } + guard let url = Bundle.module.url(forResource: filename, withExtension: "svg", subdirectory: "AgentIcons"), + let image = NSImage(contentsOf: url) else { return nil } + image.isTemplate = true + // Native Menu labels read NSImage.size rather than the SwiftUI frame. + image.size = NSSize(width: key.size, height: key.size) + images[key] = image + return image + } +} + +struct AgentToolbarButtonStyle: ButtonStyle { + @State private var isHovering = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: 14, weight: .regular)) + .foregroundStyle(AgentPanelStyle.secondary) + .frame(width: 28, height: 28) + .background( + configuration.isPressed || isHovering ? AgentPanelStyle.context : .clear, + in: RoundedRectangle(cornerRadius: 4) + ) + .contentShape(Rectangle()) + .onHover { isHovering = $0 } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift b/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift new file mode 100644 index 000000000..14a8d656a --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift @@ -0,0 +1,236 @@ +import SwiftUI +import LitheCoreContracts + +/// Provider selection is independent of the provider used for commit messages. +struct AgentProviderConfigurationView: View { + let agentID: String + let name: String + let source: AIConfigurationSourceKind? + @ObservedObject var model: AppModel + @ObservedObject var settings: AppSettings + @State private var editor: AgentProviderDraft? + @State private var deletion: AIProviderProfile? + @State private var errorMessage: String? + @State private var actionTask: Task? + + private var selected: AIProviderProfile? { settings.agentProvider(for: agentID) } + private var sources: [AIConfigurationSourceKind] { source.map { [$0] } ?? AIConfigurationSourceKind.allCases } + private var providers: [AIProviderProfile] { + settings.commitMessageAI.providers.filter { provider in + provider.credentialSource == .local && sources.contains { + provider.apiProtocol == ($0 == .codex ? .responses : .anthropicMessages) && + provider.authentication == ($0 == .codex ? .bearer : .apiKey) + } + } + } + + var body: some View { + AgentSettingsCard(title: "Providers", systemImage: "key") { + HStack { + Text("Use local configuration or add your own provider.") + .font(.system(size: 12)).foregroundStyle(LitheTheme.secondaryText) + Spacer(minLength: 8) + if let source { + Button { openEditor(source: source) } label: { Label("Add", systemImage: "plus") } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + } else { + Menu { + ForEach(sources) { kind in + Button(kind.title) { openEditor(source: kind) } + } + } label: { Label("Add", systemImage: "plus") } + } + } + ForEach(sources) { kind in + localRow(kind) + } + ForEach(providers) { provider in + providerRow(provider) + } + if providers.isEmpty { + Text("No custom providers yet.").font(.system(size: 12)).foregroundStyle(LitheTheme.tertiaryText) + } + if let selected, selected.credentialSource == .local { + Text(String(format: String(localized: "Current provider: %@"), selected.name)) + .font(.system(size: 11)).foregroundStyle(LitheTheme.secondaryText) + } + if self.selected != nil { + Button("Unlink") { + perform { try await model.useAgentProvider(nil, agentID: agentID, name: name) } + } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + } + if let errorMessage { + Text(errorMessage).font(.system(size: 12)).foregroundStyle(LitheTheme.error) + .textSelection(.enabled) + } + AgentSettingsHint("Local configuration follows your CLI files. Custom providers are saved in Lithe and do not change those files. Switching restarts this project's Agent connection; open history again to continue a saved conversation.") + } + .disabled(model.isChangingAgentProvider) + .sheet(item: $editor) { draft in + AgentProviderEditorView(model: model, draft: draft) + } + .alert("Confirm deletion", isPresented: Binding(get: { deletion != nil }, set: { if !$0 { deletion = nil } })) { + Button("Cancel", role: .cancel) { deletion = nil } + Button("Delete", role: .destructive) { + guard let provider = deletion else { return } + deletion = nil + perform { try await model.deleteAgentProvider(provider) } + } + } message: { + Text("Delete this provider and its saved API key? Agents using it will be unlinked. It is also removed from AI Providers in Settings.") + } + .onDisappear { actionTask?.cancel() } + } + + private func localRow(_ kind: AIConfigurationSourceKind) -> some View { + let active = selected?.credentialSource.configurationSource == kind + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: active ? "checkmark.circle.fill" : "doc.text.magnifyingglass") + .foregroundStyle(active ? LitheTheme.accent : LitheTheme.secondaryText) + Text(String(format: String(localized: "Local %@ configuration"), kind.title)) + .font(.system(size: 12, weight: .medium)) + Spacer(minLength: 8) + Button(active ? "Refresh" : "Use") { + perform { try await model.useLocalAgentProvider(source: kind, agentID: agentID, name: name) } + } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + } + if active, let selected { metadata(selected) } + } + .padding(10) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(active ? LitheTheme.accent : LitheTheme.panelBorder, lineWidth: 1)) + } + + private func providerRow(_ provider: AIProviderProfile) -> some View { + let active = selected?.id == provider.id + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: active ? "checkmark.circle.fill" : "server.rack") + .foregroundStyle(active ? LitheTheme.accent : LitheTheme.secondaryText) + Text(provider.name).font(.system(size: 12, weight: .medium)).lineLimit(1) + Spacer(minLength: 8) + Button(active ? "Enabled" : "Enable") { + perform { try await model.useAgentProvider(provider, agentID: agentID, name: name) } + } + .buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + .disabled(active) + Button { openEditor(source: provider.apiProtocol == .responses ? .codex : .claude, provider: provider) } label: { + Image(systemName: "pencil") + }.litheIconButton().help("Edit provider") + Button { deletion = provider } label: { Image(systemName: "trash") } + .litheIconButton().help("Delete provider") + } + metadata(provider) + } + .padding(10) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(active ? LitheTheme.accent : LitheTheme.panelBorder, lineWidth: 1)) + } + + private func metadata(_ provider: AIProviderProfile) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(provider.endpoint).lineLimit(1).truncationMode(.middle).help(provider.endpoint) + Text(provider.model).lineLimit(1) + }.font(.system(size: 11, design: .monospaced)).foregroundStyle(LitheTheme.secondaryText) + } + + private func openEditor(source: AIConfigurationSourceKind, provider: AIProviderProfile? = nil) { + do { editor = try model.agentProviderDraft(source: source, provider: provider); errorMessage = nil } + catch { errorMessage = error.localizedDescription } + } + + private func perform(_ action: @escaping @MainActor () async throws -> Void) { + guard actionTask == nil else { return } + actionTask = Task { @MainActor in + defer { actionTask = nil } + do { try await action(); errorMessage = nil } + catch is CancellationError { } + catch { errorMessage = error.localizedDescription } + } + } +} + +private struct AgentProviderEditorView: View { + @ObservedObject var model: AppModel + @State var draft: AgentProviderDraft + @State private var errorMessage: String? + @State private var saveTask: Task? + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(draft.providerID == nil ? "Add provider" : "Edit provider") + .font(.system(size: 17, weight: .semibold)) + TextField("Provider name", text: $draft.name).litheSettingsTextField() + ScrollView { + VStack(alignment: .leading, spacing: 12) { + configurationEditor(draft.source == .codex ? "config.toml" : "settings.json", text: $draft.configuration, + canFormat: draft.source == .claude) + if draft.source == .codex { + configurationEditor("auth.json", text: $draft.authentication, canFormat: true) + } + Toggle("Allow insecure HTTP", isOn: $draft.allowsInsecureHTTP) + .font(.system(size: 12)) + AgentSettingsHint("Only the API URL, model and API key are saved. Other CLI options continue to use your local configuration. The key is stored separately from settings. Editing shows a template of these saved fields.") + } + } + // Errors stay visible even when the configuration editors fill the scroll viewport. + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .font(.system(size: 12)).foregroundStyle(LitheTheme.error) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } + HStack { + Spacer() + if saveTask != nil { ProgressView().controlSize(.small) } + Button("Cancel") { dismiss() }.keyboardShortcut(.cancelAction) + .buttonStyle(LitheSecondaryButtonStyle()) + Button("Save") { save() }.keyboardShortcut(.defaultAction) + .buttonStyle(LithePrimaryButtonStyle(backgroundColor: LitheTheme.accent, restingOpacity: 1)) + } + } + .padding(20).frame(width: 540, height: 620) + .background(LitheTheme.raised) + .disabled(model.isChangingAgentProvider || saveTask != nil) + .interactiveDismissDisabled(saveTask != nil) + .onDisappear { saveTask?.cancel() } + } + + private func configurationEditor(_ title: String, text: Binding, canFormat: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title).font(.system(size: 12, weight: .medium)) + Spacer() + if canFormat { + Button("Format JSON") { + do { + let value = try JSONSerialization.jsonObject(with: Data(text.wrappedValue.utf8)) + let data = try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) + text.wrappedValue = String(decoding: data, as: UTF8.self) + errorMessage = nil + } catch { errorMessage = String(localized: "Invalid JSON. Check the configuration format.") } + }.buttonStyle(LitheSecondaryButtonStyle(horizontalPadding: 10, height: 24, fontSize: 11.5)) + } + } + MacConfigurationTextEditor(text: text, label: title) + .frame(height: title == "auth.json" ? 110 : 210) + .background(LitheTheme.inputBackground, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.panelBorder, lineWidth: 1)) + .accessibilityLabel(title) + } + } + + private func save() { + guard saveTask == nil else { return } + saveTask = Task { @MainActor in + defer { saveTask = nil } + do { try await model.saveAgentProvider(draft); dismiss() } + catch is CancellationError { } + catch { errorMessage = error.localizedDescription } + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift new file mode 100644 index 000000000..19e94c632 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift @@ -0,0 +1,339 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Search and presentation only; the Agent still owns IDs, choices and confirmed values. +enum AgentSessionSelectorPresentation { + static func filteredChoices(_ option: AgentSessionConfigOption, query: String) -> [AgentSessionConfigOption.Choice] { + let query = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return option.choices } + return option.choices.filter { + [$0.name, $0.id, $0.group ?? ""].contains { $0.localizedStandardContains(query) } + } + } + + static func title(_ option: AgentSessionConfigOption) -> String { + if option.category == "thought_level" { return String(localized: "Thinking level") } + if option.id == "fast-mode" { return String(localized: "Speed") } + return localized(option.name) + } + + static func choiceTitle(_ choice: AgentSessionConfigOption.Choice, in option: AgentSessionConfigOption) -> String { + if option.id == "fast-mode" { + if choice.id == "off" { return String(localized: "Standard") } + if choice.id == "on" { return String(localized: "Fast") } + } + return option.category == "model" ? choice.name : localized(choice.name) + } + + static func currentTitle(_ option: AgentSessionConfigOption) -> String { + option.choices.first { $0.id == option.currentValue }.map { choiceTitle($0, in: option) } ?? option.currentValue + } + + static func modeIcon(_ id: String) -> String { + switch id { + case "read-only": "bubble.left.and.bubble.right" + case "agent": "checkmark.shield" + case "agent-full-access": "bolt" + default: "slider.horizontal.3" + } + } + + static func localized(_ text: String) -> String { + String(localized: String.LocalizationValue(text)) + } +} + +/// CC GUI-style bottom toolbar with separate approval and searchable model panels. +struct AgentSessionSelectors: View { + let options: [AgentSessionConfigOption] + let agentName: String? + let isDisabled: Bool + let onSelect: (String, String) -> Void + @State private var showsModels = false + @State private var showsModes = false + + private var model: AgentSessionConfigOption? { options.first { $0.category == "model" } } + private var mode: AgentSessionConfigOption? { options.first { $0.category == "mode" } } + private var settings: [AgentSessionConfigOption] { + let remaining = options.filter { $0.id != model?.id && $0.id != mode?.id } + return remaining.filter { $0.category == "model_config" } + + remaining.filter { $0.category == "thought_level" } + + remaining.filter { $0.category != "model_config" && $0.category != "thought_level" } + } + + var body: some View { + HStack(spacing: 4) { + if let mode { + Button { showsModes.toggle() } label: { + selectorLabel { + Image(systemName: AgentSessionSelectorPresentation.modeIcon(mode.currentValue)) + Text(AgentSessionSelectorPresentation.currentTitle(mode)) + } + } + .buttonStyle(.plain) + .help(AgentSessionSelectorPresentation.localized(mode.name)) + .accessibilityLabel(Text("Approval mode")) + .accessibilityValue(AgentSessionSelectorPresentation.currentTitle(mode)) + .popover(isPresented: $showsModes, arrowEdge: .top) { + AgentModePopover(option: mode) { value in select(mode.id, value) } + .onExitCommand { showsModes = false } + } + } + if let model { + Button { showsModels.toggle() } label: { + selectorLabel { + AgentBrandIcon(name: agentName, size: 12) + Text(modelSummary(model)) + } + } + .buttonStyle(.plain) + .help(model.currentLabel) + .accessibilityLabel(Text("Model")) + .accessibilityValue(model.currentLabel) + .popover(isPresented: $showsModels, arrowEdge: .top) { + AgentModelPopover(option: model, settings: settings, agentName: agentName, onSelect: select) + .onExitCommand { showsModels = false } + } + } else if !settings.isEmpty { + Menu { + ForEach(settings) { option in + Menu(AgentSessionSelectorPresentation.title(option)) { + AgentConfigChoices(option: option) { select(option.id, $0) } + } + } + } label: { Image(systemName: "ellipsis") } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .fixedSize() + .help("More session settings") + } + } + .disabled(isDisabled) + .onChange(of: isDisabled) { disabled in + if disabled { showsModels = false; showsModes = false } + } + } + + private func modelSummary(_ model: AgentSessionConfigOption) -> String { + let effort = options.first { $0.category == "thought_level" } + return [model.currentLabel, effort.map(AgentSessionSelectorPresentation.currentTitle)].compactMap { $0 }.joined(separator: " ") + } + + private func selectorLabel(@ViewBuilder content: () -> Content) -> some View { + HStack(spacing: 5) { + content() + Image(systemName: "chevron.up").font(.system(size: 8, weight: .semibold)) + } + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 4) + .frame(height: 28) + .contentShape(Rectangle()) + .litheRowHover() + .lithePointer() + } + + private func select(_ id: String, _ value: String) { + guard !isDisabled else { return } + showsModels = false + showsModes = false + onSelect(id, value) + } +} + +struct AgentModelPopover: View { + let option: AgentSessionConfigOption + let settings: [AgentSessionConfigOption] + let agentName: String? + let onSelect: (String, String) -> Void + @State private var query = "" + @State private var selectedSettingID: String? + @FocusState private var searchFocused: Bool + private var choices: [AgentSessionConfigOption.Choice] { AgentSessionSelectorPresentation.filteredChoices(option, query: query) } + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + modelPanel + if let setting = settings.first(where: { $0.id == selectedSettingID }) { + VStack(spacing: 0) { + Text(AgentSessionSelectorPresentation.title(setting)) + .font(.system(size: 11)).foregroundStyle(AgentPanelStyle.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 8) + ScrollView { + LazyVStack(spacing: 0) { + ForEach(setting.choices) { choice in + AgentSelectorRow(isSelected: choice.id == setting.currentValue, action: { onSelect(setting.id, choice.id) }) { + Text(AgentSessionSelectorPresentation.choiceTitle(choice, in: setting)) + } + } + } + } + .frame(height: min(240, CGFloat(setting.choices.count * 32))) + } + .padding(.bottom, 5) + .frame(width: 180) + .overlay(alignment: .leading) { Divider() } + } + } + .foregroundStyle(AgentPanelStyle.text) + .background(AgentPanelStyle.header) + .onAppear { searchFocused = true } + } + + private var modelPanel: some View { + VStack(spacing: 0) { + TextField("Search models", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 12)) + .focused($searchFocused) + .padding(.horizontal, 8) + .frame(height: 28) + .background(AgentPanelStyle.canvas, in: RoundedRectangle(cornerRadius: 4)) + .overlay { RoundedRectangle(cornerRadius: 4).stroke(searchFocused ? AgentPanelStyle.focus : AgentPanelStyle.border) } + .padding(8) + .onSubmit { if let choice = choices.first { onSelect(option.id, choice.id) } } + if choices.isEmpty { + Text("No matching models") + .font(.system(size: 12)) + .foregroundStyle(AgentPanelStyle.secondary) + .frame(maxWidth: .infinity, minHeight: 36) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(Array(choices.enumerated()), id: \.element.id) { index, choice in + if let group = choice.group, index == 0 || choices[index - 1].group != group { + Text(group).font(.system(size: 10)).foregroundStyle(AgentPanelStyle.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12).padding(.vertical, 4) + } + AgentSelectorRow(isSelected: choice.id == option.currentValue, action: { onSelect(option.id, choice.id) }) { + AgentBrandIcon(name: agentName, size: 16) + Text(choice.name).lineLimit(1).truncationMode(.middle) + } + } + } + } + .frame(height: min(240, CGFloat(choices.count * 32 + choices.filter { $0.group != nil }.count * 20))) + } + if !settings.isEmpty { + Divider().overlay(AgentPanelStyle.border).padding(.vertical, 4) + ForEach(settings) { setting in + AgentModelSettingRow(option: setting, isSelected: selectedSettingID == setting.id) { + selectedSettingID = selectedSettingID == setting.id ? nil : setting.id + } + } + } + } + .padding(.bottom, 5) + .frame(width: 330) + } +} + +/// Child choices stay inside the same native popover, so opening them cannot dismiss the model panel. +private struct AgentModelSettingRow: View { + let option: AgentSessionConfigOption + let isSelected: Bool + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack { + Text(AgentSessionSelectorPresentation.title(option)) + Spacer() + Text(AgentSessionSelectorPresentation.currentTitle(option)).foregroundStyle(AgentPanelStyle.secondary) + Image(systemName: "chevron.right").font(.system(size: 9)) + } + .font(.system(size: 12)) + .padding(.horizontal, 12) + .frame(height: 28) + .background(isSelected ? AgentPanelStyle.context : .clear) + .contentShape(Rectangle()) + .litheRowHover() + } + .buttonStyle(.plain) + .lithePointer() + } +} + +private struct AgentModePopover: View { + let option: AgentSessionConfigOption + let onSelect: (String) -> Void + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(option.choices) { choice in + AgentSelectorRow(isSelected: choice.id == option.currentValue, minimumHeight: 64, action: { onSelect(choice.id) }) { + Image(systemName: AgentSessionSelectorPresentation.modeIcon(choice.id)).frame(width: 16) + VStack(alignment: .leading, spacing: 3) { + Text(AgentSessionSelectorPresentation.choiceTitle(choice, in: option)).lineLimit(1) + if let description = choice.description, !description.isEmpty { + Text(AgentSessionSelectorPresentation.localized(description)) + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + } + .padding(.vertical, 5) + .frame(width: 350, height: min(330, CGFloat(option.choices.count * 64 + 10))) + .background(AgentPanelStyle.header) + } +} + +private struct AgentSelectorRow: View { + let isSelected: Bool + var minimumHeight: CGFloat = 32 + let action: () -> Void + @ViewBuilder let content: Content + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 8) { + content + Spacer(minLength: 8) + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(LitheTheme.success) + .opacity(isSelected ? 1 : 0) + } + .font(.system(size: 12)) + .foregroundStyle(AgentPanelStyle.text) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, minHeight: minimumHeight, alignment: .leading) + .background(isSelected ? AgentPanelStyle.selected : (isHovering ? AgentPanelStyle.context : .clear)) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .lithePointer() + .onHover { isHovering = $0 } + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + +private struct AgentConfigChoices: View { + let option: AgentSessionConfigOption + let onSelect: (String) -> Void + + var body: some View { + ForEach(option.choices) { choice in + Button { onSelect(choice.id) } label: { + let title = AgentSessionSelectorPresentation.choiceTitle(choice, in: option) + if choice.id == option.currentValue { + Label(title, systemImage: "checkmark") + } else { + Text(choice.group.map { "\($0): \(title)" } ?? title) + } + } + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift new file mode 100644 index 000000000..e864c590f --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -0,0 +1,474 @@ +import AppKit +import SwiftUI +import LitheAgentConversationModule + +/// Message list of the selected conversation, followed by the pending +/// permission request and a summary of this turn's tool activity. +struct AgentTranscriptView: View { + @ObservedObject var feature: AgentConnectionModel + let agentName: String? + let agentVersion: String? + let agents: [AgentOption] + let onSelectAgent: (String) -> Void + var searchText = "" + var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } + @State private var showsAgentPicker = false + + var body: some View { + let conversation = feature.selectedConversation + let messages = conversation?.messages ?? [] + VStack(spacing: 0) { + if conversation?.isLoading != true && messages.isEmpty && feature.pendingNewConversationPrompt == nil { + AgentHeroView(agentName: agentName, agentVersion: agentVersion) { + showsAgentPicker = true + } + .popover(isPresented: $showsAgentPicker, arrowEdge: .bottom) { + VStack(alignment: .leading, spacing: 2) { + ForEach(agents) { agent in + Button { + showsAgentPicker = false + onSelectAgent(agent.id) + } label: { + HStack { + Text(agent.name) + Spacer() + if agent.name == agentName { Image(systemName: "checkmark") } + } + .padding(.horizontal, 10) + .frame(height: 26) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .litheRowHover() + } + } + .padding(6) + .frame(width: 180) + } + } else { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + if conversation?.isLoading == true { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading conversation…").foregroundStyle(LitheTheme.secondaryText) + } + } + if !searchText.isEmpty && !messages.contains(where: { $0.text.localizedStandardContains(searchText) }) { + Text("No matching messages") + .foregroundStyle(AgentPanelStyle.secondary) + } + ForEach(messages.filter { searchText.isEmpty || $0.text.localizedStandardContains(searchText) }) { message in + AgentMessageRow(message: message, onOpenFile: onOpenFile).id(message.id) + } + if feature.selectedSessionID == nil, let prompt = feature.pendingNewConversationPrompt { + AgentMessageRow(message: AgentConversationMessage(id: "pending", role: .user, text: prompt)) + .id("pending") + } + if conversation?.isResponding == true || feature.isCreatingSession { + AgentThinkingRow(isCancelling: conversation?.isCancelling == true).id("responding") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + .onChange(of: messages.last?.text) { _ in + if searchText.isEmpty, let last = messages.last { proxy.scrollTo(last.id, anchor: .bottom) } + } + .onChange(of: messages.count) { _ in + if searchText.isEmpty, let last = messages.last { proxy.scrollTo(last.id, anchor: .bottom) } + } + } + } + if let permission = conversation?.permission { + AgentPermissionCard(permission: permission, answer: { feature.answerPermission(optionID: $0) }, onOpenFile: onOpenFile) + } + AgentActivitySummaryBar(messages: messages) + } + } +} + +private struct AgentThinkingRow: View { + var isCancelling = false + var body: some View { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text(isCancelling ? "Stopping…" : "Thinking…") + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + } + .padding(.leading, 2) + } +} + +/// Centered agent mark with its version; tapping it switches agents. +struct AgentHeroView: View { + let agentName: String? + let agentVersion: String? + let onTap: () -> Void + @State private var isHovering = false + + var body: some View { + VStack(spacing: 16) { + Button(action: onTap) { + AgentBrandIcon(name: agentName, size: 60) + .foregroundStyle(isHovering ? AgentPanelStyle.secondary : AgentPanelStyle.logo) + } + .buttonStyle(.plain) + .lithePointer() + .accessibilityLabel("Switch Agent") + .onHover { isHovering = $0 } + .overlay(alignment: .topLeading) { + if let agentVersion, !agentVersion.isEmpty { + Text("v\(agentVersion)") + .font(.system(size: 10, weight: .medium)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundStyle(AgentPanelStyle.versionText) + .background(AgentPanelStyle.versionAccent.opacity(0.1), in: RoundedRectangle(cornerRadius: 4)) + .overlay(RoundedRectangle(cornerRadius: 4).stroke(AgentPanelStyle.versionAccent.opacity(0.4))) + .fixedSize() + .offset(x: 70, y: -2) + } + } + Text(agentName.map { String(format: String(localized: "Send a message to %@"), $0) } + ?? String(localized: "Choose an Agent to start")) + .font(.system(size: 14)) + .foregroundStyle(AgentPanelStyle.logo) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .help("Switch Agent") + } +} + +/// Three segments below the transcript: tool tasks, running, edits. Kept +/// visible even when empty so the layout does not jump when the first tool +/// call arrives. Edits count tool calls the agent titled as file changes. +struct AgentActivitySummaryBar: View { + let messages: [AgentConversationMessage] + + private var tools: [AgentConversationMessage] { messages.filter { $0.role == .tool } } + private var running: Int { tools.filter { $0.toolStatus == .inProgress || $0.toolStatus == .pending }.count } + private var failed: Int { tools.filter { $0.toolStatus == .failed }.count } + private var edits: Int { + tools.filter { message in + message.toolDetails.kind == "edit" || message.toolDetails.kind == "delete" + }.count + } + + var body: some View { + HStack(spacing: 0) { + segment(systemImage: "checklist", title: "Tasks", value: tools.count, tint: failed > 0 ? LitheTheme.error : nil) + Divider().frame(height: 14).overlay(LitheTheme.divider) + segment(systemImage: "arrow.triangle.2.circlepath", title: "Running", value: running, tint: running > 0 ? LitheTheme.accent : nil) + Divider().frame(height: 14).overlay(LitheTheme.divider) + segment(systemImage: "pencil", title: "Edits", value: edits, tint: nil) + } + .font(.system(size: 11)) + .foregroundStyle(AgentPanelStyle.muted) + .frame(height: 32) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .padding(.horizontal, 18) + .padding(.bottom, 4) + } + + private func segment(systemImage: String, title: LocalizedStringKey, value: Int, tint: Color?) -> some View { + HStack(spacing: 5) { + Image(systemName: systemImage).font(.system(size: 10)) + Text(title) + if value > 0 { + Text("\(value)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + .foregroundStyle(tint ?? LitheTheme.primaryText) + } + } + .frame(maxWidth: .infinity) + } +} + +private struct AgentPermissionCard: View { + let permission: AgentPermissionPrompt + let answer: (String?) -> Void + let onOpenFile: (AgentToolDetails.Location) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + Image(systemName: "hand.raised.fill") + .foregroundStyle(LitheTheme.warning) + Text("Permission required") + .font(.system(size: 12.5, weight: .semibold)) + } + Text(permission.title) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.primaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + if !permission.details.isEmpty { + ScrollView { + AgentToolEvidenceView(details: permission.details, onOpenFile: onOpenFile) + } + .frame(maxHeight: 180) + } + VStack(alignment: .leading, spacing: 8) { + ForEach(permission.choices) { choice in + Button(choice.label) { answer(choice.id) } + .buttonStyle(.bordered) + .tint(choice.kind?.hasPrefix("reject") == true ? LitheTheme.error : LitheTheme.accent) + } + if !permission.choices.contains(where: { $0.kind?.hasPrefix("reject") == true }) { + Button("Deny") { answer(nil) } + } + } + .controlSize(.small) + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(LitheTheme.raised, in: RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(LitheTheme.warning.opacity(0.5), lineWidth: 1) + ) + .padding(.horizontal, 12) + .padding(.bottom, 8) + } +} + +private struct AgentMessageRow: View { + let message: AgentConversationMessage + var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } + + var body: some View { + switch message.role { + case .user: + HStack { + Spacer(minLength: 40) + Text(message.text) + .font(.system(size: 13)) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(LitheTheme.accent.opacity(0.18), in: RoundedRectangle(cornerRadius: 10)) + } + case .agent: + AgentMarkdownMessage(text: message.text) + .frame(maxWidth: .infinity, alignment: .leading) + case .tool: + AgentToolCallRow(message: message, onOpenFile: onOpenFile) + } + } +} + +private struct AgentToolCallRow: View { + let message: AgentConversationMessage + let onOpenFile: (AgentToolDetails.Location) -> Void + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { expanded.toggle() } label: { + header + } + .buttonStyle(.plain) + .help(expanded ? "Hide tool details" : "Show tool details") + if expanded && !message.toolDetails.isEmpty { + AgentToolEvidenceView(details: message.toolDetails, onOpenFile: onOpenFile) + .padding(.horizontal, 10) + .padding(.bottom, 8) + } + } + .background(LitheTheme.raised, in: RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.panelBorder, lineWidth: 1)) + } + + private var header: some View { + HStack(alignment: .top, spacing: 8) { + Group { + if message.toolStatus == .inProgress || message.toolStatus == .pending { + ProgressView().controlSize(.mini) + } else { + Image(systemName: icon) + .foregroundStyle(tint) + } + } + .frame(width: 14, height: 14) + Text(message.text) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(3) + .textSelection(.enabled) + Spacer(minLength: 0) + Image(systemName: expanded ? "chevron.down" : "chevron.right") + .font(.system(size: 10)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + } + + private var icon: String { + switch message.toolStatus { + case .completed: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .interrupted: "pause.circle" + default: "circle.dotted" + } + } + + private var tint: Color { + switch message.toolStatus { + case .completed: LitheTheme.success + case .failed: LitheTheme.error + default: LitheTheme.tertiaryText + } + } +} + +private struct AgentToolEvidenceView: View { + let details: AgentToolDetails + let onOpenFile: (AgentToolDetails.Location) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(details.locations.enumerated()), id: \.offset) { _, location in + Button { onOpenFile(location) } label: { + Label(location.path + (location.line.map { ":\($0)" } ?? ""), systemImage: "doc") + .lineLimit(2) + .truncationMode(.middle) + } + .buttonStyle(.plain) + .font(.system(size: 11, design: .monospaced)) + .help(location.path) + } + if let input = details.input { evidence("Input", text: input) } + ForEach(Array(details.content.enumerated()), id: \.offset) { _, content in + evidence(content.title, text: content.text) + } + if let output = details.output { evidence("Result", text: output) } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func evidence(_ title: String, text: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.system(size: 11, weight: .medium)).foregroundStyle(LitheTheme.secondaryText) + ScrollView([.horizontal, .vertical]) { + Text(text) + .font(.system(size: 11, design: .monospaced)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 160) + } + } +} + +/// Splits the reply into prose and fenced code blocks. Prose uses inline +/// Markdown; code blocks get a monospaced box with a copy button. +private struct AgentMarkdownMessage: View { + let text: String + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(Array(Self.segments(of: text).enumerated()), id: \.offset) { _, segment in + switch segment { + case .prose(let prose): + Text(Self.markdown(prose)) + .font(.system(size: 13)) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + case .code(let language, let code): + AgentCodeBlock(language: language, code: code) + } + } + } + } + + enum Segment: Equatable { + case prose(String) + case code(language: String, code: String) + } + + /// Fences that have not been closed yet (still streaming) are treated as code. + static func segments(of text: String) -> [Segment] { + var segments: [Segment] = [] + var prose = "" + var code = "" + var language = "" + var inCode = false + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + if line.hasPrefix("```") { + if inCode { + segments.append(.code(language: language, code: code.trimmingCharacters(in: .newlines))) + code = "" + inCode = false + } else { + if !prose.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + segments.append(.prose(prose.trimmingCharacters(in: .newlines))) + } + prose = "" + language = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) + inCode = true + } + continue + } + if inCode { code += line + "\n" } else { prose += line + "\n" } + } + if inCode { + segments.append(.code(language: language, code: code.trimmingCharacters(in: .newlines))) + } else if !prose.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + segments.append(.prose(prose.trimmingCharacters(in: .newlines))) + } + return segments + } + + private static func markdown(_ text: String) -> AttributedString { + let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .inlineOnlyPreservingWhitespace + ) + return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text) + } +} + +private struct AgentCodeBlock: View { + let language: String + let code: String + @State private var didCopy = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(language.isEmpty ? String(localized: "code") : language) + .font(.system(size: 10.5, weight: .medium)) + .foregroundStyle(LitheTheme.tertiaryText) + Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + didCopy = true + Task { try? await Task.sleep(for: .seconds(1.5)); didCopy = false } + } label: { + Image(systemName: didCopy ? "checkmark" : "doc.on.doc") + .font(.system(size: 10.5)) + } + .buttonStyle(.plain) + .lithePointer() + .foregroundStyle(didCopy ? LitheTheme.success : LitheTheme.tertiaryText) + .help("Copy code") + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(LitheTheme.toolHeaderInactive) + ScrollView(.horizontal, showsIndicators: false) { + Text(code) + .font(LitheTheme.codeFont) + .textSelection(.enabled) + .padding(10) + } + } + .background(LitheTheme.raised) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay(RoundedRectangle(cornerRadius: 6).stroke(LitheTheme.panelBorder, lineWidth: 1)) + } +} diff --git a/macos/Sources/Lithe/Views/App/SettingsView.swift b/macos/Sources/Lithe/Views/App/SettingsView.swift index 6d3d3745c..fe00f9ebe 100644 --- a/macos/Sources/Lithe/Views/App/SettingsView.swift +++ b/macos/Sources/Lithe/Views/App/SettingsView.swift @@ -10,6 +10,9 @@ final class SettingsViewState: ObservableObject { @Published var hiddenDirectoriesDraft = "" @Published var hiddenFilePatternsDraft = "" @Published var aiAPIKeyDraft = "" + /// Provider shown in Settings › AI Providers; independent of the one + /// commit messages use. + @Published var editingProviderID: UUID? @Published var isFormatPickerPresented = false @Published var detectedTerminalShells: [String] = [] @Published var knownTerminalShells: [String] = [] @@ -70,6 +73,7 @@ struct SettingsView: View { .onChange(of: settings.hiddenDirectoryNames) { _ in syncVisibilityDrafts() } .onChange(of: settings.hiddenFilePatterns) { _ in syncVisibilityDrafts() } .onChange(of: settings.commitMessageAI.activeProviderID) { _ in syncAIProviderDraft() } + .onChange(of: viewState.editingProviderID) { _ in syncAIProviderDraft() } .onChange(of: initialCategory) { category in viewState.searchQuery = "" viewState.selection = category @@ -191,7 +195,9 @@ struct SettingsView: View { case .lsp: ["LSP", "Language server"] case .ai: - ["AI & Commit", "AI provider", "Model", "API key", "Commit message"] + ["AI & Commit", "Commit message", "Pull request"] + case .providers: + ["AI Providers", "AI provider", "Model", "API key", "Endpoint", "Responses", "Anthropic"] case .git: ["Git", "Fetch", "Tags", "Submodules", "Prune", "Commit identity", "Committer name", "Committer email", "Configuration scope", "user.name", "user.email"] case .updates: @@ -254,6 +260,7 @@ struct SettingsView: View { case .project: EmptyView() case .run: EmptyView() case .ai: aiSettings + case .providers: providersSettings case .git: VStack(alignment: .leading, spacing: 14) { GitExecutionSettingsView(settings: settings) @@ -674,67 +681,73 @@ struct SettingsView: View { URL(fileURLWithPath: path).lastPathComponent } - private var aiSettings: some View { + private var providersSettings: some View { VStack(alignment: .leading, spacing: 18) { - group("AI provider") { + group("AI providers") { if settings.commitMessageAI.providers.isEmpty { Text("No AI provider is configured yet.") .foregroundStyle(LitheTheme.secondaryText) } else { - row("Profile") { + row("Provider") { LitheSettingsSelect( selection: Binding( - get: { settings.commitMessageAI.activeProviderID ?? settings.commitMessageAI.providers[0].id }, - set: { settings.selectCommitMessageProvider($0) } + get: { editingProvider?.id ?? settings.commitMessageAI.providers[0].id }, + set: { viewState.editingProviderID = $0 } ), options: settings.commitMessageAI.providers.map(\.id), width: 240, - accessibilityLabel: "Profile", + accessibilityLabel: "Provider", title: providerTitle ) } + if let editingProvider { + Text(providerUsage(editingProvider)) + .font(LitheTheme.smallFont) + .foregroundStyle(LitheTheme.secondaryText) + } HStack(spacing: 8) { Button("Add Provider") { - settings.addCommitMessageProvider() + viewState.editingProviderID = settings.addAIProvider() syncAIProviderDraft() } .buttonStyle(LitheSecondaryButtonStyle()) Button("Remove") { - settings.removeActiveCommitMessageProvider() + if let id = editingProvider?.id { settings.removeAIProvider(id) } + viewState.editingProviderID = nil syncAIProviderDraft() } .buttonStyle(LitheSecondaryButtonStyle()) - .disabled(settings.activeCommitMessageProvider == nil) + .disabled(editingProvider == nil) } } - if settings.activeCommitMessageProvider != nil { - TextField("Provider name", text: activeProviderTextBinding(\.name)) + if editingProvider != nil { + TextField("Provider name", text: editingProviderTextBinding(\.name)) .litheSettingsTextField() - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) row("API protocol") { LitheSettingsSelect( - selection: activeProviderProtocolBinding(), + selection: editingProviderProtocolBinding(), options: CommitMessageAPIProtocol.allCases, width: 240, accessibilityLabel: "API protocol", title: \CommitMessageAPIProtocol.title ) - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) } - TextField("API URL", text: activeProviderTextBinding(\.endpoint)) + TextField("API URL", text: editingProviderTextBinding(\.endpoint)) .litheSettingsTextField() - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) - if settings.activeCommitMessageProvider?.usesInsecureHTTP == true { + .disabled(editingProviderIsConfigurationManaged) + if editingProvider?.usesInsecureHTTP == true { LitheSettingsCheckbox( - isOn: activeProviderBoolBinding(\.allowsInsecureHTTP), + isOn: editingProviderBoolBinding(\.allowsInsecureHTTP), title: "Allow insecure HTTP" ) - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) Label( - settings.activeCommitMessageProvider?.allowsInsecureHTTP == true + editingProvider?.allowsInsecureHTTP == true ? "HTTP sends the API credential without encryption. Use only a trusted endpoint." : "HTTP is blocked until you explicitly allow it for this provider.", systemImage: "exclamationmark.triangle" @@ -742,29 +755,29 @@ struct SettingsView: View { .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.warning) } - TextField("Model", text: activeProviderTextBinding(\.model)) + TextField("Model", text: editingProviderTextBinding(\.model)) .litheSettingsTextField() - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) HStack(spacing: 8) { SecureField("API key or token", text: $viewState.aiAPIKeyDraft) .litheSettingsTextField() - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) Button("Save Key") { - model.saveActiveCommitMessageAPIKey(viewState.aiAPIKeyDraft) + if let editingProvider { model.saveAPIKey(viewState.aiAPIKeyDraft, for: editingProvider) } } .buttonStyle(LitheSecondaryButtonStyle()) - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) } LitheSettingsCheckbox( - isOn: activeProviderBoolBinding(\.requiresAPIKey), + isOn: editingProviderBoolBinding(\.requiresAPIKey), title: "Provider requires an API key" ) - .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) + .disabled(editingProviderIsConfigurationManaged) - if model.activeCommitMessageCredentialIsConfigurationManaged, - let description = model.activeCommitMessageConfigurationSourceDescription { + if editingProviderIsConfigurationManaged, + let description = editingProvider.flatMap(model.configurationSourceDescription(for:)) { Text(LocalizedStringKey(description)) .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) @@ -832,13 +845,66 @@ struct SettingsView: View { } } - if !model.activeCommitMessageCredentialIsConfigurationManaged { + if !editingProviderIsConfigurationManaged { Text("API keys are stored in Lithe's local application data and are never written to Lithe settings.") .font(LitheTheme.smallFont) .foregroundStyle(LitheTheme.secondaryText) } } + } + .frame(maxWidth: 760, alignment: .leading) + } + + /// Provider being edited, falling back to the commit message provider. + private var editingProvider: AIProviderProfile? { + let providers = settings.commitMessageAI.providers + return providers.first { $0.id == viewState.editingProviderID } + ?? settings.activeCommitMessageProvider + ?? providers.first + } + + private var editingProviderIsConfigurationManaged: Bool { + editingProvider.map(model.credentialIsConfigurationManaged(_:)) ?? false + } + + /// Which features use `provider`, e.g. "Used by: commit messages, Codex". + private func providerUsage(_ provider: AIProviderProfile) -> String { + var users: [String] = [] + if settings.commitMessageAI.activeProviderID == provider.id { users.append(String(localized: "commit messages")) } + users += settings.agentConfigurations.values + .filter { $0.providerID == provider.id } + .map(\.name) + .sorted() + return users.isEmpty + ? String(localized: "Not used yet.") + : String(format: String(localized: "Used by: %@"), users.joined(separator: ", ")) + } + + private var aiSettings: some View { + VStack(alignment: .leading, spacing: 18) { + group("Provider") { + if settings.commitMessageAI.providers.isEmpty { + Text("Add an AI provider in AI Providers first.") + .foregroundStyle(LitheTheme.secondaryText) + } else { + row("Commit messages use") { + LitheSettingsSelect( + selection: Binding( + get: { settings.commitMessageAI.activeProviderID ?? settings.commitMessageAI.providers[0].id }, + set: { settings.selectCommitMessageProvider($0) } + ), + options: settings.commitMessageAI.providers.map(\.id), + width: 240, + accessibilityLabel: "Commit message provider", + title: providerTitle + ) + } + } + Button("Manage AI Providers…") { viewState.selection = .providers } + .buttonStyle(LitheSecondaryButtonStyle()) + } + group("Commit message generation") { row("Reasoning effort") { LitheSettingsSelect( @@ -1352,7 +1418,7 @@ struct SettingsView: View { } private func syncAIProviderDraft() { - viewState.aiAPIKeyDraft = model.activeCommitMessageAPIKey + viewState.aiAPIKeyDraft = editingProvider.map(model.apiKey(for:)) ?? "" } private func providerTitle(_ id: UUID) -> String { @@ -1367,44 +1433,40 @@ struct SettingsView: View { syncAIProviderDraft() } - private func activeProviderTextBinding( + private func editingProviderTextBinding( _ keyPath: WritableKeyPath ) -> Binding { Binding( - get: { settings.activeCommitMessageProvider?[keyPath: keyPath] ?? "" }, + get: { editingProvider?[keyPath: keyPath] ?? "" }, set: { value in - settings.updateActiveCommitMessageProvider { provider in - provider[keyPath: keyPath] = value - } + guard let id = editingProvider?.id else { return } + settings.updateAIProvider(id) { provider in provider[keyPath: keyPath] = value } } ) } - private func activeProviderProtocolBinding() -> Binding { + private func editingProviderProtocolBinding() -> Binding { Binding( - get: { settings.activeCommitMessageProvider?.apiProtocol ?? .responses }, + get: { editingProvider?.apiProtocol ?? .responses }, set: { value in - settings.updateActiveCommitMessageProvider { provider in - provider.apiProtocol = value - } + guard let id = editingProvider?.id else { return } + settings.updateAIProvider(id) { provider in provider.apiProtocol = value } } ) } - private func activeProviderBoolBinding( + private func editingProviderBoolBinding( _ keyPath: WritableKeyPath ) -> Binding { Binding( - get: { settings.activeCommitMessageProvider?[keyPath: keyPath] ?? true }, + get: { editingProvider?[keyPath: keyPath] ?? true }, set: { value in - settings.updateActiveCommitMessageProvider { provider in - provider[keyPath: keyPath] = value - } + guard let id = editingProvider?.id else { return } + settings.updateAIProvider(id) { provider in provider[keyPath: keyPath] = value } } ) } - private var footer: some View { HStack { Button("Restore Defaults") { settings.restoreDefaults() } diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 38e028e42..868eb453a 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -8,12 +8,51 @@ private let editorTabCoordinateSpaceName = "lithe.editor-tab-strip" enum EditorDocumentIconResolver { static func kind( for url: URL, - resolvedJavaKind: LitheIconKind? + resolvedKind: LitheIconKind? ) -> LitheIconKind { - if url.pathExtension.lowercased() == "java", let resolvedJavaKind { - return resolvedJavaKind + resolvedKind ?? LitheIcons.kind(for: url, isDirectory: false) + } +} + +private struct EditorDocumentTabIcon: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var document: EditorDocument + let size: CGFloat + @State private var resolvedKind: LitheIconKind? + + private struct ResolutionKey: Hashable { + let path: String + let contentRevision: UInt64 + } + + private var resolutionKey: ResolutionKey { + ResolutionKey(path: document.url.standardizedFileURL.path, + contentRevision: document.iconContentRevision) + } + + var body: some View { + LitheIcon( + kind: EditorDocumentIconResolver.kind(for: document.url, resolvedKind: resolvedKind), + size: size + ) + .task(id: resolutionKey) { + let key = resolutionKey + let url = document.url + resolvedKind = nil + if url.pathExtension.lowercased() == "java" { + guard let kind = await model.javaIconKind(for: url), + !Task.isCancelled, resolutionKey == key else { return } + resolvedKind = kind + } else if LitheIcons.kind(for: url, isDirectory: false) == .generic { + let resolved = await WorkspaceFileIconResolver.resolve( + for: url, + suggested: .generic, + storage: model.services.fileStorage + ) + guard !Task.isCancelled, resolutionKey == key else { return } + resolvedKind = resolved.kind + } } - return LitheIcons.kind(for: url, isDirectory: false) } } @@ -56,7 +95,6 @@ struct EditorAreaView: View { @State private var documentPreviewModes: [UUID: DocumentPreviewMode] = [:] @State private var markdownScrollPositions: [UUID: MarkdownScrollPosition] = [:] @State private var hoveredPreviewMode: DocumentPreviewMode? - @State private var resolvedJavaDocumentIconKinds: [String: LitheIconKind] = [:] var body: some View { let _ = LitheSignpost.bodyEvaluated("EditorAreaView") @@ -677,7 +715,7 @@ struct EditorAreaView: View { isActive: Bool ) -> some View { let label = HStack(spacing: 7) { - editorDocumentIcon(document, size: 13) + EditorDocumentTabIcon(document: document, size: 13) editorTabTitle(document) EditorTabDirtyIndicator(document: document) } @@ -744,7 +782,7 @@ struct EditorAreaView: View { private func editorTabDragPreview(_ document: EditorDocument) -> some View { HStack(spacing: 7) { - editorDocumentIcon(document, size: 13) + EditorDocumentTabIcon(document: document, size: 13) .foregroundStyle(LitheTheme.accent) Text(document.displayName) @@ -1107,28 +1145,6 @@ struct EditorAreaView: View { } } - private func editorDocumentIcon( - _ document: EditorDocument, - size: CGFloat - ) -> some View { - let path = document.url.standardizedFileURL.path - let resolvedKind = resolvedJavaDocumentIconKinds[path] - return LitheIcon( - kind: EditorDocumentIconResolver.kind( - for: document.url, - resolvedJavaKind: resolvedKind - ), - size: size - ) - .task(id: path) { - guard document.url.pathExtension.lowercased() == "java", - resolvedKind == nil else { return } - let kind = await model.javaIconKind(for: document.url) - guard !Task.isCancelled, let kind else { return } - resolvedJavaDocumentIconKinds[path] = kind - } - } - private func editorTabContextMenu( for document: EditorDocument, at index: Int diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift index 95af30669..b3d4f9c87 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchHoverTooltip.swift @@ -1,6 +1,7 @@ import SwiftUI enum WorkbenchHoverTooltipPlacement { + case above case below case leading case trailing @@ -155,6 +156,8 @@ struct WorkbenchHoverTooltipLayout: Layout { let gap: CGFloat = 6 let preferredOrigin: CGPoint switch placement { + case .above: + preferredOrigin = CGPoint(x: sourceFrame.midX - size.width / 2, y: sourceFrame.minY - size.height - gap) case .below: preferredOrigin = CGPoint(x: sourceFrame.midX - size.width / 2, y: sourceFrame.maxY + gap) case .leading: diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index 4988838cf..105aa507c 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -5,6 +5,7 @@ import LitheExecutionModule import LitheGitModule import LitheLanguageIntelligenceModule import LitheTerminalModule +import LitheAgentConversationModule @MainActor enum WorkbenchModuleUIComposition { @@ -12,6 +13,7 @@ enum WorkbenchModuleUIComposition { do { return try WorkbenchModuleUIRegistry(registrations: [ terminalRegistration, + agentRegistration, gitRegistration, languageRegistration, executionRegistration, @@ -22,6 +24,26 @@ enum WorkbenchModuleUIComposition { } }() + private static let agentRegistration = WorkbenchModuleUIRegistry.Registration( + contributions: AgentConversationModule.moduleContributions, + actions: [ + .init(id: "agent.conversation.toggle", perform: { $0.toggleAgentConversation() }) + ], + renderers: [ + .init( + id: "agent.conversation", + ideaAssetPath: nil, + isVisible: { $0.workspaceURL != nil }, + isSelected: { $0.workbenchFeature.isVisible(.agent) }, + // The panel renders its full layout even before the optional + // module is active, so the loading placeholder is not used. + content: { model in AnyView(AgentConversationView(model: model)) }, + contentIdentity: { WorkbenchModuleUIRegistry.Renderer.featureIdentity($0.agentConversationFeatureIfActive) }, + rightSidebarBehavior: .docked + ) + ] + ) + private static let terminalRegistration = WorkbenchModuleUIRegistry.Registration( contributions: TerminalModule.moduleContributions, actions: [ diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index 9918bbf96..1539b04aa 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -643,6 +643,8 @@ struct WorkbenchView: View { .font(.system(size: 12.5, weight: isActive ? .semibold : .medium)) .foregroundStyle(isActive ? LitheTheme.primaryText : LitheTheme.secondaryText) + AgentAttentionIndicator(model: projectModel) + if let documentName = projectModel.activeDocument?.displayName { Text("· \(documentName)") .font(.system(size: 11.5)) @@ -2470,3 +2472,18 @@ struct WorkbenchBackgroundPresetArtwork: View { } } } + +/// Marks a project tab whose Agent conversation waits for a permission decision. +private struct AgentAttentionIndicator: View { + @ObservedObject var model: AppModel + + var body: some View { + if model.agentConversationNeedsAttention { + Circle() + .fill(LitheTheme.accent) + .frame(width: 6, height: 6) + .help("The Agent is waiting for your permission") + .accessibilityLabel("The Agent is waiting for your permission") + } + } +} diff --git a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift index e11922b9e..69778b677 100644 --- a/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift +++ b/macos/Sources/Lithe/Views/Workspace/ProjectSidebarView.swift @@ -456,7 +456,7 @@ private struct FileNodeRow: View { } } } else { - fileRow + fileRow.draggable(node.url) } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift new file mode 100644 index 000000000..83e407ec4 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -0,0 +1,703 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Owns one agent's connection and conversations within a project, and +/// batches streaming text before UI updates. +@MainActor +public final class AgentConnectionModel: ObservableObject { + public enum ConnectionState: Equatable, Sendable { + case idle + case connecting + case ready + case failed(String) + } + + @Published public private(set) var connectionState: ConnectionState = .idle + /// Name and version the agent reported on `ready`, for the panel header. + @Published public private(set) var agentName: String? + @Published public private(set) var agentVersion: String? + @Published public private(set) var sessions: [AgentSessionSummary] = [] + /// `nil` selects a new, not yet created conversation. + @Published public private(set) var selectedSessionID: String? + @Published public private(set) var conversations: [String: AgentConversation] = [:] + /// Sessions shown as tabs, in the order they were opened in this panel. + @Published public private(set) var openSessionIDs: [String] = [] + /// Prompt of a new conversation while its session is being created. + @Published public private(set) var pendingNewConversationPrompt: String? + @Published public private(set) var canLoadSessions = false + @Published public private(set) var isRefreshingSessions = false + @Published public private(set) var historyError: String? + @Published public private(set) var errorMessage: String? + + /// Called when any conversation starts or stops waiting for a permission + /// decision, so a background project can signal it. + public var onAttentionChanged: ((Bool) -> Void)? + + private let transport: any AgentConversationTransport + private var connection: (any AgentConnection)? + private var eventTask: Task? + private var eventContinuation: AsyncStream.Continuation? + private var closeTask: Task? + private var canListSessions = false + private var nextToken = 0 + /// Prompts waiting for a session: keyed by new-session token or by the + /// session ID being loaded. + private var queuedPrompts: [String: AgentPrompt] = [:] + private var loadTokens: [String: String] = [:] + private var pendingText: [String: String] = [:] + private var flushTask: Task? + private var needsAttention = false + @Published private var createToken: String? + private var loadBackups: [String: AgentConversation] = [:] + /// Locally prepared sessions without a submitted prompt or upstream history evidence. + /// Codex does not persist their rollout until the first prompt, so they cannot be resumed. + private var unpromptedSessionIDs: Set = [] + private var historyContinuations: [String: CheckedContinuation<[AgentConversationMessage], Error>] = [:] + private var historyRefreshToken: String? + + public init(transport: any AgentConversationTransport) { + self.transport = transport + } + + public var hasActiveConnection: Bool { connection != nil } + public var isCreatingSession: Bool { createToken != nil } + public var hasPendingPermission: Bool { conversations.values.contains { $0.permission != nil } } + public var selectedConversation: AgentConversation? { + selectedSessionID.flatMap { conversations[$0] } + } + + // MARK: Connection + + /// Start the agent for this project. Does nothing while already connected. + public func connect(configuration: AgentLaunchConfiguration) throws { + guard connection == nil else { return } + guard closeTask == nil else { throw AgentConversationError.sessionStopping } + let (events, continuation) = AsyncStream.makeStream() + errorMessage = nil + connectionState = .connecting + do { + connection = try transport.open(configuration: configuration) { event in + continuation.yield(event) + } + } catch { + continuation.finish() + connectionState = .failed(error.localizedDescription) + throw error + } + eventContinuation = continuation + // One consumer keeps events in the order the connection produced them. + eventTask = Task { [weak self] in + for await event in events { + guard !Task.isCancelled else { break } + self?.receive(event) + } + } + } + + /// Show why the agent could not start, e.g. incomplete settings. + public func reportConnectionFailure(_ message: String) { + guard connection == nil else { return } + connectionState = .failed(message) + } + + /// Stop the agent and wait for its process tree to exit. + public func stop() async { + let old = detachConnection(failure: nil) + await old?.close() + if let closeTask { await closeTask.value } + } + + public var canRefreshSessions: Bool { canListSessions && connectionState == .ready } + + public func refreshSessions() { + guard canRefreshSessions, !isRefreshingSessions else { return } + let token = makeToken() + historyRefreshToken = token + isRefreshingSessions = true + historyError = nil + if !sendCommand(["kind": "listSessions", "token": token]) { + historyError = errorMessage + isRefreshingSessions = false + historyRefreshToken = nil + } + } + + public func canExportTranscript(_ sessionID: String) -> Bool { + let conversation = conversations[sessionID] + guard conversation?.isResponding != true, conversation?.isLoading != true, + conversation?.pendingConfigToken == nil else { return false } + return conversation?.hasCompleteHistory == true + || (canLoadSessions && connectionState == .ready) + } + + /// Replays an unopened transcript through the existing bounded ACP request. + /// Cancellation releases the waiter; the Agent may finish its replay normally. + public func historyTranscript(_ sessionID: String) async throws -> [AgentConversationMessage] { + try Task.checkCancellation() + guard canExportTranscript(sessionID) else { throw AgentConversationError.cannotResume } + if let conversation = conversations[sessionID], conversation.hasCompleteHistory { + return conversation.messages + } + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { continuation.resume(throwing: CancellationError()); return } + historyContinuations[sessionID] = continuation + beginLoad(sessionID) + } + } onCancel: { + Task { @MainActor [weak self] in + self?.historyContinuations.removeValue(forKey: sessionID)?.resume(throwing: CancellationError()) + } + } + } + + // MARK: Conversations + + public func startNewConversation() { + guard !isCreatingSession else { return } + selectedSessionID = nil + errorMessage = nil + prepareConversation() + } + + /// Prepare an empty session so upstream settings are available before sending. + public func prepareConversation() { + guard connectionState == .ready else { return } + if let sessionID = selectedSessionID { + if conversations[sessionID]?.isAttached != true { selectSession(sessionID) } + return + } + guard createToken == nil else { return } + let token = makeToken() + createToken = token + if !sendCommand(["kind": "newSession", "token": token]) { createToken = nil } + } + + public func setConfigOption(_ id: String, value: String) { + guard let sessionID = selectedSessionID, let conversation = conversations[sessionID], + conversation.isAttached, !conversation.isResponding, !conversation.isLoading, + conversation.pendingConfigToken == nil, + let option = conversation.configOptions.first(where: { $0.id == id }), + option.choices.contains(where: { $0.id == value }), option.currentValue != value else { return } + let token = makeToken() + conversations[sessionID]?.pendingConfigToken = token + conversations[sessionID]?.configurationError = nil + if !sendCommand(["kind": "setConfigOption", "token": token, "sessionId": sessionID, "configId": id, "value": value]) { + conversations[sessionID]?.pendingConfigToken = nil + } + } + public func selectSession(_ sessionID: String) { + selectedSessionID = sessionID + errorMessage = nil + openTab(sessionID) + let conversation = conversations[sessionID] + if conversation?.isAttached != true, conversation?.isLoading != true, + connection != nil, canLoadSessions { + beginLoad(sessionID) + } + } + + /// Close a tab. A conversation that is still responding or waiting for a + /// permission decision stays open so its outcome is not lost. + public func closeConversation(_ sessionID: String) { + guard let conversation = conversations[sessionID], + !conversation.isResponding, !conversation.isLoading, + conversation.pendingConfigToken == nil, conversation.permission == nil else { return } + openSessionIDs.removeAll { $0 == sessionID } + conversations[sessionID] = nil + unpromptedSessionIDs.remove(sessionID) + pendingText[sessionID] = nil + queuedPrompts[sessionID] = nil + if selectedSessionID == sessionID { + selectedSessionID = openSessionIDs.last + } + } + + public func send(_ text: String, files: [AgentFileReference] = []) throws { + let prompt = AgentPrompt( + text: text.trimmingCharacters(in: .whitespacesAndNewlines), + files: try AgentFileReference.adding(files.map(\.url), to: []) + ) + guard !prompt.isEmpty else { return } + guard connection != nil else { throw AgentConversationError.notConnected } + guard let sessionID = selectedSessionID else { + if createToken == nil { prepareConversation() } + guard let token = createToken else { throw AgentConversationError.notConnected } + queuedPrompts[token] = prompt + pendingNewConversationPrompt = prompt.displayText + errorMessage = nil + return + } + let conversation = conversations[sessionID] ?? AgentConversation() + guard !conversation.isResponding else { throw AgentConversationError.sessionBusy } + guard conversation.pendingConfigToken == nil else { throw AgentConversationError.configurationPending } + if conversation.isLoading { + queuedPrompts[sessionID] = prompt + } else if conversation.isAttached { + guard startPrompt(prompt, in: sessionID) else { + throw AgentConversationError.sendFailed(errorMessage ?? AgentConversationError.notConnected.localizedDescription) + } + } else if canLoadSessions { + queuedPrompts[sessionID] = prompt + beginLoad(sessionID) + } else { + throw AgentConversationError.cannotResume + } + } + + public func cancel() { + guard let sessionID = selectedSessionID, + conversations[sessionID]?.isResponding == true, + conversations[sessionID]?.isCancelling != true else { return } + conversations[sessionID]?.isCancelling = true + conversations[sessionID]?.pendingPermissions.removeAll() + updateAttention() + if !sendCommand(["kind": "cancel", "sessionId": sessionID]) { + conversations[sessionID]?.isCancelling = false + } + } + + public func answerPermission(optionID: String?) { + guard let sessionID = selectedSessionID, + let permission = conversations[sessionID]?.permission else { return } + conversations[sessionID]?.pendingPermissions.removeFirst() + updateAttention() + sendCommand([ + "kind": "permission", + "requestId": permission.id, + "optionId": optionID.map { $0 as Any } ?? NSNull() + ]) + } + + // MARK: Events + + func receive(_ json: String) { + guard let data = json.data(using: .utf8), + let event = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let kind = event["kind"] as? String else { return } + let sessionID = event["sessionId"] as? String + let token = event["token"] as? String + switch kind { + case "ready": + connectionState = .ready + agentName = event["agentName"] as? String + agentVersion = event["agentVersion"] as? String + canLoadSessions = event["canLoadSessions"] as? Bool ?? false + canListSessions = event["canListSessions"] as? Bool ?? false + refreshSessions() + case "sessions": + guard isRefreshingSessions, token == historyRefreshToken else { return } + mergeSessions(event["sessions"] as? [[String: Any]] ?? []) + historyRefreshToken = nil + isRefreshingSessions = false + case "sessionCreated": + guard let sessionID, let token, token == createToken else { return } + conversations[sessionID, default: AgentConversation()].configOptions = AgentSessionConfigOption.parse(event["configOptions"]) + sessionCreated(sessionID, token: token) + case "sessionLoaded": + guard let sessionID, let token, loadTokens.removeValue(forKey: token) != nil else { return } + unpromptedSessionIDs.remove(sessionID) + flushPendingText() + conversations[sessionID, default: AgentConversation()].isLoading = false + conversations[sessionID]?.isAttached = true + conversations[sessionID]?.hasCompleteHistory = true + loadBackups[sessionID] = nil + conversations[sessionID]?.configOptions = AgentSessionConfigOption.parse(event["configOptions"]) + historyContinuations.removeValue(forKey: sessionID)?.resume(returning: conversations[sessionID]?.messages ?? []) + if let prompt = queuedPrompts.removeValue(forKey: sessionID) { + startPrompt(prompt, in: sessionID) + } + case "sessionConfigured": + guard let sessionID, conversations[sessionID]?.pendingConfigToken == token else { return } + applyConfiguration(event["configOptions"], to: sessionID) + conversations[sessionID]?.pendingConfigToken = nil + case "turnCancelling": + guard let sessionID else { return } + conversations[sessionID]?.isCancelling = true + case "update": + guard let sessionID, let update = event["update"] as? [String: Any] else { return } + apply(update, to: sessionID) + case "permission": + guard let sessionID, + conversations[sessionID]?.isCancelling != true, + let requestID = event["requestId"] as? String, + let request = event["request"] as? [String: Any] else { return } + let prompt = permissionPrompt(requestID, request, sessionID: sessionID) + conversations[sessionID, default: AgentConversation()].enqueuePermission(prompt) + updateAttention() + case "turnFinished": + guard let sessionID else { return } + flushPendingText() + conversations[sessionID]?.isResponding = false + conversations[sessionID]?.isCancelling = false + conversations[sessionID]?.interruptPendingTools() + conversations[sessionID]?.pendingPermissions.removeAll() + conversations[sessionID]?.errorMessage = stopReasonMessage(event["stopReason"] as? String) + updateAttention() + case "requestFailed": + requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? "The Agent request failed.") + case "stopped": + let old = detachConnection(failure: event["message"] as? String) + if let old { + closeTask = Task { [weak self] in + await old.close() + self?.closeTask = nil + } + } + default: + break + } + } + + private func sessionCreated(_ sessionID: String, token: String) { + guard token == createToken else { return } + createToken = nil + let prompt = queuedPrompts.removeValue(forKey: token) + if !sessions.contains(where: { $0.id == sessionID }) { + sessions.insert(AgentSessionSummary(id: sessionID, title: prompt.map { Self.provisionalTitle($0.displayText) }), at: 0) + } + var conversation = conversations[sessionID] ?? AgentConversation() + conversation.isAttached = true + conversation.hasCompleteHistory = true + conversations[sessionID] = conversation + unpromptedSessionIDs.insert(sessionID) + openTab(sessionID) + pendingNewConversationPrompt = nil + if selectedSessionID == nil { selectedSessionID = sessionID } + if let prompt { startPrompt(prompt, in: sessionID) } + } + + private func requestFailed(token: String?, sessionID: String?, message: String) { + if let token, token == historyRefreshToken { + historyRefreshToken = nil + isRefreshingSessions = false + historyError = message + } else if let sessionID, let token, conversations[sessionID]?.pendingConfigToken == token { + conversations[sessionID]?.pendingConfigToken = nil + conversations[sessionID]?.configurationError = message + } else if let token, token == createToken { + queuedPrompts.removeValue(forKey: token) + createToken = nil + pendingNewConversationPrompt = nil + errorMessage = message + } else if let token, let loading = loadTokens.removeValue(forKey: token) { + queuedPrompts.removeValue(forKey: loading) + pendingText[loading] = nil + if let backup = loadBackups.removeValue(forKey: loading) { conversations[loading] = backup } + conversations[loading]?.isLoading = false + conversations[loading]?.errorMessage = message + historyContinuations.removeValue(forKey: loading)?.resume(throwing: AgentConversationError.sendFailed(message)) + } else if token != nil { + errorMessage = message + } else if let sessionID, conversations[sessionID] != nil { + flushPendingText() + conversations[sessionID]?.isResponding = false + conversations[sessionID]?.isCancelling = false + conversations[sessionID]?.interruptPendingTools() + conversations[sessionID]?.pendingPermissions.removeAll() + conversations[sessionID]?.errorMessage = message + updateAttention() + } else { + errorMessage = message + } + } + + private func mergeSessions(_ entries: [[String: Any]]) { + let listed = entries.compactMap { entry -> AgentSessionSummary? in + guard let id = entry["sessionId"] as? String else { return nil } + let local = sessions.first { $0.id == id } + return AgentSessionSummary( + id: id, + title: entry["title"] as? String ?? local?.title, + updatedAt: entry["updatedAt"] as? String + ) + } + let listedIDs = Set(listed.map(\.id)) + // A listed session is owned by upstream history even if the local transcript is empty. + unpromptedSessionIDs.subtract(listedIDs) + // Sessions created in this run may not be persisted by the agent yet. + sessions = sessions.filter { !listedIDs.contains($0.id) && conversations[$0.id] != nil } + listed + } + + private func apply(_ update: [String: Any], to sessionID: String) { + switch update["sessionUpdate"] as? String { + case "usage_update": + guard connectionState == .ready else { return } + conversations[sessionID, default: AgentConversation()].contextUsage = AgentContextUsage.parse(update) + case "config_option_update": + applyConfiguration(update["configOptions"], to: sessionID) + case "agent_message_chunk": + guard let text = Self.text(of: update) else { return } + pendingText[sessionID, default: ""] += text + scheduleFlush() + case "user_message_chunk": + guard let text = Self.text(of: update) else { return } + flushPendingText() + append(text, role: .user, to: sessionID) + case "tool_call", "tool_call_update": + guard let toolCallID = update["toolCallId"] as? String else { return } + flushPendingText() + upsertTool(toolCallID, update: update, in: sessionID) + case "session_info_update": + guard let title = update["title"] as? String, !title.isEmpty else { return } + if let index = sessions.firstIndex(where: { $0.id == sessionID }) { + sessions[index].title = title + } else { + sessions.insert(AgentSessionSummary(id: sessionID, title: title), at: 0) + } + default: + break + } + } + + private func applyConfiguration(_ value: Any?, to sessionID: String) { + let options = AgentSessionConfigOption.parse(value) + let oldModels = conversations[sessionID]?.configOptions.filter { $0.category == "model" } ?? [] + let newModels = options.filter { $0.category == "model" } + if oldModels.map(\.currentValue) != newModels.map(\.currentValue) { + conversations[sessionID]?.contextUsage = nil + } + conversations[sessionID, default: AgentConversation()].configOptions = options + } + + private func append(_ text: String, role: AgentConversationMessage.Role, to sessionID: String) { + var conversation = conversations[sessionID] ?? AgentConversation() + if let last = conversation.messages.indices.last, conversation.messages[last].role == role { + conversation.messages[last].text += text + } else { + conversation.messages.append(AgentConversationMessage(role: role, text: text)) + } + conversations[sessionID] = conversation + } + + private func upsertTool(_ toolCallID: String, update: [String: Any], in sessionID: String) { + var conversation = conversations[sessionID] ?? AgentConversation() + let id = "tool:\(toolCallID)" + let status = (update["status"] as? String).flatMap(AgentConversationMessage.ToolStatus.init(rawValue:)) + let title = (update["title"] as? String).flatMap { $0.isEmpty ? nil : $0 } + if let index = conversation.messages.firstIndex(where: { $0.id == id }) { + if let title { conversation.messages[index].text = title } + if let status { conversation.messages[index].toolStatus = status } + conversation.messages[index].toolDetails.merge(update) + } else { + conversation.messages.append(AgentConversationMessage( + id: id, + role: .tool, + text: title ?? "Tool call", + toolStatus: status ?? .pending + )) + conversation.messages[conversation.messages.count - 1].toolDetails.merge(update) + } + conversations[sessionID] = conversation + } + + private func permissionPrompt(_ requestID: String, _ request: [String: Any], sessionID: String) -> AgentPermissionPrompt { + let tool = request["toolCall"] as? [String: Any] + let options = (request["options"] as? [[String: Any]] ?? []).compactMap { option -> AgentPermissionChoice? in + guard let id = option["optionId"] as? String, let label = option["name"] as? String else { return nil } + return AgentPermissionChoice(id: id, label: label, kind: option["kind"] as? String) + } + var prompt = AgentPermissionPrompt( + id: requestID, + title: tool?["title"] as? String ?? "Allow the Agent to continue?", + choices: options + ) + if let tool { + if let id = tool["toolCallId"] as? String, + let known = conversations[sessionID]?.messages.first(where: { $0.id == "tool:\(id)" }) { + prompt.details = known.toolDetails + } + prompt.details.merge(tool) + } + return prompt + } + + // MARK: Helpers + + @discardableResult + private func startPrompt(_ prompt: AgentPrompt, in sessionID: String) -> Bool { + var command: [String: Any] = ["kind": "prompt", "sessionId": sessionID, "text": prompt.text] + if !prompt.files.isEmpty { command["files"] = prompt.files.map(\.commandValue) } + guard sendCommand(command) else { return false } + unpromptedSessionIDs.remove(sessionID) + var conversation = conversations[sessionID] ?? AgentConversation() + conversation.messages.append(AgentConversationMessage(role: .user, text: prompt.displayText)) + conversation.isResponding = true + conversation.errorMessage = nil + conversations[sessionID] = conversation + return true + } + + private func beginLoad(_ sessionID: String) { + let token = makeToken() + loadTokens[token] = sessionID + loadBackups[sessionID] = conversations[sessionID] + pendingText[sessionID] = nil + // The agent replays the whole history, so rebuild it from scratch. + var conversation = AgentConversation() + conversation.isLoading = true + conversations[sessionID] = conversation + if !sendCommand(["kind": "loadSession", "token": token, "sessionId": sessionID]) { + requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? "The Agent request failed.") + } + } + + /// Returns false and records the error when the command could not be queued. + @discardableResult + private func sendCommand(_ command: [String: Any]) -> Bool { + guard let connection else { + errorMessage = AgentConversationError.notConnected.localizedDescription + return false + } + do { + let data = try JSONSerialization.data(withJSONObject: command) + try connection.send(commandJSON: String(decoding: data, as: UTF8.self)) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } + + /// Clears connection state; returns the connection that still has to be closed. + private func detachConnection(failure: String?) -> (any AgentConnection)? { + flushPendingText() + flushTask?.cancel() + flushTask = nil + // Events still buffered for the old connection must not be applied. + eventContinuation?.finish() + eventContinuation = nil + eventTask?.cancel() + eventTask = nil + let old = connection + connection = nil + connectionState = failure.map(ConnectionState.failed) ?? .idle + canListSessions = false + canLoadSessions = false + isRefreshingSessions = false + historyRefreshToken = nil + for continuation in historyContinuations.values { + continuation.resume(throwing: AgentConversationError.notConnected) + } + historyContinuations.removeAll() + queuedPrompts.removeAll() + loadTokens.removeAll() + for (id, backup) in loadBackups { conversations[id] = backup } + loadBackups.removeAll() + discardUnpersistedEmptySessions() + createToken = nil + pendingNewConversationPrompt = nil + for id in conversations.keys { + conversations[id]?.contextUsage = nil + conversations[id]?.isResponding = false + conversations[id]?.interruptPendingTools() + conversations[id]?.isCancelling = false + conversations[id]?.pendingConfigToken = nil + conversations[id]?.isLoading = false + conversations[id]?.isAttached = false + conversations[id]?.pendingPermissions.removeAll() + } + updateAttention() + return old + } + + private func discardUnpersistedEmptySessions() { + let discarded = unpromptedSessionIDs.filter { conversations[$0]?.messages.isEmpty == true } + unpromptedSessionIDs.removeAll() + // Never infer this from an empty transcript alone: unloaded history must survive. + for id in discarded { conversations[id] = nil } + sessions.removeAll { discarded.contains($0.id) } + openSessionIDs.removeAll { discarded.contains($0) } + if let selectedSessionID, discarded.contains(selectedSessionID) { + self.selectedSessionID = nil + } + } + + private func openTab(_ sessionID: String) { + guard !openSessionIDs.contains(sessionID) else { return } + openSessionIDs.append(sessionID) + } + + private func makeToken() -> String { + nextToken += 1 + return "lithe-\(nextToken)" + } + + private func updateAttention() { + let attention = hasPendingPermission + guard attention != needsAttention else { return } + needsAttention = attention + onAttentionChanged?(attention) + } + + private func scheduleFlush() { + guard flushTask == nil else { return } + flushTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(40)) + guard let self, !Task.isCancelled else { return } + self.flushTask = nil + self.flushPendingText() + } + } + + private func flushPendingText() { + let pending = pendingText + pendingText.removeAll() + for (sessionID, text) in pending where !text.isEmpty { + append(text, role: .agent, to: sessionID) + } + } + + private func stopReasonMessage(_ reason: String?) -> String? { + switch reason { + case "max_tokens": "The response stopped at the model's token limit." + case "max_turn_requests": "The Agent stopped after reaching its request limit for this turn." + case "refusal": "The Agent declined to continue." + default: nil + } + } + + private static func text(of update: [String: Any]) -> String? { + guard let content = update["content"] as? [String: Any], + content["type"] as? String == "text" else { return nil } + return content["text"] as? String + } + + private static func provisionalTitle(_ prompt: String) -> String { + let line = prompt.split(whereSeparator: \.isNewline).first.map(String.init) ?? prompt + return line.count > 60 ? String(line.prefix(60)) + "…" : line + } +} + +public enum AgentConversationError: LocalizedError, Equatable { + case featureDisabled + case noAgentConfigured + case moduleStarting + case missingCommand + case missingProvider + case missingAPIKey + case notConnected + case sessionStopping + case cannotResume + case configurationPending + case sessionBusy + case sendFailed(String) + + public var errorDescription: String? { + switch self { + case .featureDisabled: String(localized: "Agent conversation is turned off. Turn it on in the panel settings to send messages.") + case .noAgentConfigured: String(localized: "No Agent is ready yet. Open the panel settings to install an Agent and choose a local or custom provider.") + case .moduleStarting: String(localized: "The Agent module is still starting. Try again in a moment.") + case .missingCommand: String(localized: "Set the custom Agent's executable in the panel settings.") + case .missingProvider: String(localized: "Choose a local or custom provider in the Agent panel settings.") + case .missingAPIKey: String(localized: "This Agent's provider has no API key. Update its local configuration or edit the custom provider in the panel settings.") + case .notConnected: String(localized: "The Agent is not running. Connect to start a conversation.") + case .sessionStopping: String(localized: "The previous Agent is still stopping. Try again shortly.") + case .cannotResume: String(localized: "This Agent cannot reopen earlier conversations. Start a new conversation.") + case .sessionBusy: String(localized: "The Agent is still responding in this conversation.") + case .sendFailed(let message): message + case .configurationPending: String(localized: "Wait for the Agent configuration to finish updating.") + } + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift new file mode 100644 index 000000000..9e1cc6569 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift @@ -0,0 +1,23 @@ +import Foundation + +/// Agent-reported context occupancy, distinct from cumulative billing tokens. +public struct AgentContextUsage: Equatable, Sendable { + public let usedTokens: UInt64 + public let capacityTokens: UInt64 + + public init?(usedTokens: UInt64, capacityTokens: UInt64) { + guard capacityTokens > 0 else { return nil } + self.usedTokens = usedTokens + self.capacityTokens = capacityTokens + } + + /// Preserve the reported values even if occupancy exceeds the window size. + public var fraction: Double { Double(usedTokens) / Double(capacityTokens) } + + static func parse(_ update: [String: Any]) -> Self? { + struct Payload: Decodable { let used: UInt64; let size: UInt64 } + guard let data = try? JSONSerialization.data(withJSONObject: update), + let payload = try? JSONDecoder().decode(Payload.self, from: data) else { return nil } + return Self(usedTokens: payload.used, capacityTokens: payload.size) + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationFeatureModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationFeatureModel.swift new file mode 100644 index 000000000..9483b38ba --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationFeatureModel.swift @@ -0,0 +1,112 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// An agent the user set up in Settings › Agents. +public struct AgentOption: Identifiable, Equatable, Sendable { + public let id: String + public let name: String + /// Model or provider label shown next to the agent in the composer. + public let modelName: String? + + public init(id: String, name: String, modelName: String? = nil) { + self.id = id + self.name = name + self.modelName = modelName + } +} + +/// The project's Agent panel: which agent is shown and one lazily created +/// connection per agent. Agents that were never selected start no process. +@MainActor +public final class AgentConversationFeatureModel: ObservableObject { + @Published public private(set) var agents: [AgentOption] = [] + @Published public private(set) var selectedAgentID: String? + + /// Called when any agent starts or stops waiting for a permission decision. + public var onAttentionChanged: ((Bool) -> Void)? + + private let transport: any AgentConversationTransport + private var connections: [String: AgentConnectionModel] = [:] + private var attention: Set = [] + private var histories: [String: AgentHistoryFeatureModel] = [:] + private var workspaceURL: URL? + private let historyPersistence: (any AgentHistoryPersisting)? + private let historyExporter: (any AgentHistoryExporting)? + + public init(transport: any AgentConversationTransport, workspaceURL: URL? = nil, + historyPersistence: (any AgentHistoryPersisting)? = nil, + historyExporter: (any AgentHistoryExporting)? = nil) { + self.transport = transport + self.workspaceURL = workspaceURL + self.historyPersistence = historyPersistence + self.historyExporter = historyExporter + } + + public var hasActiveConnection: Bool { connections.values.contains { $0.hasActiveConnection } } + + /// The shell creates its module runtime before opening a project. Bind the + /// actual project before any history UI is created, including setup failures. + public func bindWorkspace(_ workspaceURL: URL) { + guard self.workspaceURL == nil else { return } + self.workspaceURL = workspaceURL + histories.removeAll() + } + + /// Connection model of the selected agent, created on first use. + public var selectedConnection: AgentConnectionModel? { + selectedAgentID.map(connection(for:)) + } + + /// Replace the configured agents, keeping the selection when still present. + public func setAgents(_ agents: [AgentOption]) { + self.agents = agents + if selectedAgentID.map({ id in !agents.contains { $0.id == id } }) ?? true { + selectedAgentID = agents.first?.id + } + } + + public func selectAgent(_ agentID: String) { + guard agents.contains(where: { $0.id == agentID }) else { return } + selectedAgentID = agentID + } + + public func connection(for agentID: String) -> AgentConnectionModel { + if let existing = connections[agentID] { return existing } + let model = AgentConnectionModel(transport: transport) + model.onAttentionChanged = { [weak self] needsAttention in + self?.updateAttention(agentID, needsAttention) + } + connections[agentID] = model + return model + } + + public func history(for agentID: String) -> AgentHistoryFeatureModel { + if let history = histories[agentID] { return history } + let history = AgentHistoryFeatureModel(connection: connection(for: agentID), workspaceURL: workspaceURL, + agentID: agentID, persistence: historyPersistence, exporter: historyExporter) + histories[agentID] = history + return history + } + + /// Stop every agent of this project and wait for their processes to exit. + public func stop() async { + for history in histories.values { history.cancelExport() } + for connection in connections.values { + await connection.stop() + } + for history in histories.values { await history.stop() } + } + + private func updateAttention(_ agentID: String, _ needsAttention: Bool) { + let before = !attention.isEmpty + if needsAttention { + attention.insert(agentID) + } else { + attention.remove(agentID) + } + if before != !attention.isEmpty { + onAttentionChanged?(!attention.isEmpty) + } + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift new file mode 100644 index 000000000..eb664a1b9 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift @@ -0,0 +1,90 @@ +import Foundation + +/// One entry of the agent-owned conversation history for the workspace. +public struct AgentSessionSummary: Identifiable, Equatable, Sendable { + public let id: String + public var title: String? + public var updatedAt: String? + + public init(id: String, title: String? = nil, updatedAt: String? = nil) { + self.id = id + self.title = title + self.updatedAt = updatedAt + } +} + +public struct AgentConversationMessage: Identifiable, Equatable, Sendable { + public enum Role: Equatable, Sendable { case user, agent, tool } + public enum ToolStatus: String, Equatable, Sendable { + case pending + case inProgress = "in_progress" + case completed + case failed + /// The connection or turn ended without a final tool status. + case interrupted + } + + public let id: String + public let role: Role + public var text: String + public var toolStatus: ToolStatus? + public var toolDetails = AgentToolDetails() + + public init(id: String = UUID().uuidString, role: Role, text: String, toolStatus: ToolStatus? = nil) { + self.id = id + self.role = role + self.text = text + self.toolStatus = toolStatus + } +} + +public struct AgentPermissionChoice: Identifiable, Equatable, Sendable { + public let id: String + public let label: String + public var kind: String? +} + +public struct AgentPermissionPrompt: Identifiable, Equatable, Sendable { + public let id: String + public let title: String + public let choices: [AgentPermissionChoice] + public var details = AgentToolDetails() +} + +/// Display state of one conversation session. +public struct AgentConversation: Equatable, Sendable { + public var messages: [AgentConversationMessage] = [] + public var contextUsage: AgentContextUsage? + public var isResponding = false + public var isLoading = false + public var isCancelling = false + public var configOptions: [AgentSessionConfigOption] = [] + public var pendingConfigToken: String? + public var configurationError: String? + /// A new process must load this session before prompting it again. + public var isAttached = false + /// Only successful creation or loading establishes a complete history snapshot. + /// Unlike attachment, this remains valid after disconnecting the process. + public var hasCompleteHistory = false + var pendingPermissions: [AgentPermissionPrompt] = [] + public var permission: AgentPermissionPrompt? { pendingPermissions.first } + public var errorMessage: String? + + public init() {} + + mutating func enqueuePermission(_ prompt: AgentPermissionPrompt) { + if let index = pendingPermissions.firstIndex(where: { $0.id == prompt.id }) { + pendingPermissions[index] = prompt + } else { + pendingPermissions.append(prompt) + } + } + + mutating func interruptPendingTools() { + for index in messages.indices where messages[index].role == .tool { + if messages[index].toolStatus == .pending || messages[index].toolStatus == .inProgress { + messages[index].toolStatus = .interrupted + } + } + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift new file mode 100644 index 000000000..b57eb9389 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift @@ -0,0 +1,53 @@ +import Foundation + +/// A native file URL supplied by the user, not a copied or preloaded attachment. +public struct AgentFileReference: Identifiable, Equatable, Sendable { + public static let maximumCount = 32 + public let url: URL + public var id: String { url.absoluteString } + public var name: String { url.lastPathComponent } + + public init(url: URL) throws { + guard url.isFileURL, url.host == nil || url.host == "" || url.host == "localhost", + !url.path.isEmpty, url.query == nil, url.fragment == nil else { + throw AgentFileReferenceError.invalidURL + } + self.url = url.standardizedFileURL + } + + /// Preserve drop order, reject an invalid batch atomically, and bound draft size. + public static func adding(_ urls: [URL], to existing: [Self]) throws -> [Self] { + var result = existing + var ids = Set(existing.map(\.id)) + for url in urls { + let file = try Self(url: url) + if ids.insert(file.id).inserted { result.append(file) } + guard result.count <= maximumCount else { throw AgentFileReferenceError.tooManyFiles } + } + return result + } + + var commandValue: [String: String] { ["uri": id, "name": name] } +} + +public enum AgentFileReferenceError: LocalizedError { + case invalidURL + case tooManyFiles + + public var errorDescription: String? { + switch self { + case .invalidURL: String(localized: "Only local files can be attached to an Agent message.") + case .tooManyFiles: String(localized: "Attach up to 32 files per message.") + } + } +} + +/// Keep file context with the message while a session is being created or loaded. +struct AgentPrompt { + let text: String + let files: [AgentFileReference] + var isEmpty: Bool { text.isEmpty && files.isEmpty } + var displayText: String { + ([text].filter { !$0.isEmpty } + files.map { "📎 \($0.url.path)" }).joined(separator: "\n\n") + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift new file mode 100644 index 000000000..709ba8683 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift @@ -0,0 +1,155 @@ +import Combine +import Foundation +import LitheCoreContracts + +/// Manages Lithe's history annotations and exports Agent-owned replayed messages. +@MainActor +public final class AgentHistoryFeatureModel: ObservableObject { + @Published public private(set) var metadata: [String: AgentHistoryMetadata] = [:] + @Published public private(set) var errorMessage: String? + @Published public private(set) var isExporting = false + private let connection: AgentConnectionModel + private let workspaceURL: URL? + private let agentID: String + private let persistence: (any AgentHistoryPersisting)? + private let exporter: (any AgentHistoryExporting)? + private var exportTask: Task? + + public init(connection: AgentConnectionModel, workspaceURL: URL?, agentID: String, + persistence: (any AgentHistoryPersisting)? = nil, exporter: (any AgentHistoryExporting)? = nil) { + self.connection = connection + self.workspaceURL = workspaceURL + self.agentID = agentID + self.persistence = persistence + self.exporter = exporter + do { metadata = try readMetadata() } + catch { errorMessage = error.localizedDescription } + } + + public func title(for session: AgentSessionSummary) -> String? { + metadata[session.id]?.title ?? session.title + } + + @discardableResult + public func rename(_ sessionID: String, title: String) -> Bool { + let title = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { return false } + return change([sessionID]) { $0.title = title } + } + + @discardableResult + public func setFavorite(_ ids: [String], _ favorite: Bool) -> Bool { + change(ids) { $0.isFavorite = favorite } + } + + @discardableResult + public func setHidden(_ ids: [String], _ hidden: Bool) -> Bool { + change(ids) { $0.isHidden = hidden } + } + + public func canExport(_ ids: [String]) -> Bool { + let known = Set(connection.sessions.map(\.id)) + return !ids.isEmpty && !isExporting && exporter != nil + && ids.allSatisfy { known.contains($0) && connection.canExportTranscript($0) } + } + + /// Loads unopened sessions sequentially, without changing the active tab. + public func export(_ ids: [String]) { + guard canExport(ids), let exporter else { return } + guard let destination = exporter.chooseDestination() else { return } + let sessions = connection.sessions.filter { ids.contains($0.id) } + isExporting = true + errorMessage = nil + exportTask = Task { [weak self, connection] in + do { + var documents: [AgentHistoryDocument] = [] + for session in sessions { + try Task.checkCancellation() + let messages = try await connection.historyTranscript(session.id) + documents.append(AgentHistoryDocument( + id: session.id, title: self?.title(for: session) ?? session.title, + messages: messages + )) + } + try Task.checkCancellation() + try await exporter.writeMarkdown(AgentHistoryDocument.markdown(documents), to: destination) + } catch is CancellationError { + // Module shutdown or an explicit cancel owns this task's end. + } catch { + if !Task.isCancelled { self?.errorMessage = error.localizedDescription } + } + self?.isExporting = false + self?.exportTask = nil + } + } + + public func cancelExport() { exportTask?.cancel() } + + public func stop() async { + exportTask?.cancel() + await exportTask?.value + } + + private func readMetadata() throws -> [String: AgentHistoryMetadata] { + guard let workspaceURL, let persistence else { return metadata } + return try persistence.load(workspaceURL: workspaceURL, agentID: agentID) + } + + private func change(_ ids: [String], mutation: (inout AgentHistoryMetadata) -> Void) -> Bool { + let validIDs = Set(connection.sessions.map(\.id)).intersection(ids) + guard !validIDs.isEmpty else { return false } + do { + // Re-read before mutation so two windows do not overwrite each other's annotations. + var updated = try readMetadata() + for id in validIDs { mutation(&updated[id, default: AgentHistoryMetadata()]) } + if let workspaceURL, let persistence { + try persistence.save(updated, workspaceURL: workspaceURL, agentID: agentID) + } + metadata = updated + errorMessage = nil + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} + +public struct AgentHistoryDocument: Sendable { + public let id: String + public let title: String? + public let messages: [AgentConversationMessage] + + public init(id: String, title: String?, messages: [AgentConversationMessage]) { + self.id = id + self.title = title + self.messages = messages + } + + /// A portable transcript; tool messages retain their input, output and content. + public static func markdown(_ documents: [Self]) -> String { + documents.map { document in + let title = (document.title ?? String(localized: "Untitled conversation")) + .components(separatedBy: .newlines).joined(separator: " ") + var sections = ["# \(title)", "Session ID: \(document.id)"] + for message in document.messages { + let role: String + switch message.role { + case .user: role = String(localized: "You") + case .agent: role = "Agent" + case .tool: role = String(localized: "Tool") + } + var content = "## \(role)\n\n\(message.text)" + if let status = message.toolStatus { content += "\n\nStatus: \(status.rawValue)" } + for detail in [message.toolDetails.input, message.toolDetails.output].compactMap({ $0 }) { + content += "\n\n\(detail)" + } + for block in message.toolDetails.content { + content += "\n\n\(block.text)" + } + sections.append(content) + } + return sections.joined(separator: "\n\n") + }.joined(separator: "\n\n---\n\n") + "\n" + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift new file mode 100644 index 000000000..0f331848d --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift @@ -0,0 +1,113 @@ +import Foundation + +/// Agent-owned choices, never a hardcoded product model or permission catalog. +public struct AgentSessionConfigOption: Identifiable, Equatable, Sendable { + public struct Choice: Identifiable, Equatable, Sendable { + public var id: String + public var name: String + public var group: String? + public var description: String? = nil + } + + public var id: String + public var name: String + public var category: String? + public var currentValue: String + public var choices: [Choice] + + public var currentLabel: String { choices.first { $0.id == currentValue }?.name ?? currentValue } + + static func parse(_ value: Any?) -> [Self] { + (value as? [[String: Any]] ?? []).compactMap { option in + guard option["type"] as? String == "select", + let id = option["id"] as? String, + let name = option["name"] as? String, + let current = option["currentValue"] as? String else { return nil } + let choices = (option["options"] as? [[String: Any]] ?? []).flatMap { entry -> [Choice] in + let group = entry["name"] as? String + let items = entry["options"] as? [[String: Any]] ?? [entry] + return items.compactMap { + guard let value = $0["value"] as? String, let name = $0["name"] as? String else { return nil } + return Choice(id: value, name: name, group: entry["options"] == nil ? nil : group, + description: $0["description"] as? String) + } + } + return Self(id: id, name: name, category: option["category"] as? String, + currentValue: current, choices: choices) + } + } +} + +/// Bounded presentation of ACP tool evidence. Partial updates replace only present fields. +public struct AgentToolDetails: Equatable, Sendable { + public struct Location: Equatable, Sendable { + public var path: String + public var line: Int? + + public func fileURL(in workspace: URL) -> URL? { + let root = workspace.standardizedFileURL + let url = URL(fileURLWithPath: path, relativeTo: root).standardizedFileURL + guard url.pathComponents.starts(with: root.pathComponents), + url.pathComponents.count > root.pathComponents.count else { return nil } + return url + } + } + public struct Content: Equatable, Sendable { + public var title: String + public var text: String + } + + public var kind: String? + public var input: String? + public var output: String? + public var locations: [Location] = [] + public var content: [Content] = [] + public var isEmpty: Bool { input == nil && output == nil && locations.isEmpty && content.isEmpty } + static let textLimit = 32_768 + + mutating func merge(_ update: [String: Any]) { + if let kind = update["kind"] as? String { self.kind = kind } + if let input = update["rawInput"] { self.input = Self.display(input) } + if let output = update["rawOutput"] { self.output = Self.display(output) } + if let locations = update["locations"] as? [[String: Any]] { + self.locations = locations.prefix(100).compactMap { + guard let path = $0["path"] as? String else { return nil } + return Location(path: path, line: ($0["line"] as? Int).flatMap { $0 > 0 ? $0 : nil }) + } + } + if let content = update["content"] as? [[String: Any]] { + self.content = content.prefix(100).compactMap { item in + switch item["type"] as? String { + case "content": + guard let block = item["content"] as? [String: Any] else { return nil } + if let text = block["text"] as? String { + return Content(title: "Output", text: Self.bounded(text)) + } + return Content(title: "Content", text: block["type"] as? String ?? "Unsupported content") + case "diff": + let old = item["oldText"] as? String ?? "" + let new = item["newText"] as? String ?? "" + return Content(title: item["path"] as? String ?? "Diff", + text: Self.bounded("---\n" + old + "\n+++\n" + new)) + case "terminal": + return Content(title: "Terminal", text: item["terminalId"] as? String ?? "") + default: return nil + } + } + } + } + + private static func display(_ value: Any) -> String? { + if value is NSNull { return nil } + if let text = value as? String { return bounded(text) } + guard JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys]) else { + return bounded(String(describing: value)) + } + return bounded(String(decoding: data, as: UTF8.self)) + } + + private static func bounded(_ text: String) -> String { + text.count > textLimit ? String(text.prefix(textLimit)) + "\n[...]" : text + } +} diff --git a/macos/Sources/LitheAgentConversationModule/Module/AgentConversationModule.swift b/macos/Sources/LitheAgentConversationModule/Module/AgentConversationModule.swift new file mode 100644 index 000000000..a8f7be2d3 --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Module/AgentConversationModule.swift @@ -0,0 +1,66 @@ +import Foundation +import LitheCoreContracts +import LitheModuleAPI + +@MainActor +public final class AgentConversationCapability: NSObject { + public let feature: AgentConversationFeatureModel + + public init(feature: AgentConversationFeatureModel) { + self.feature = feature + } +} + +/// Optional, on-demand ACP conversation capability. +@MainActor +public final class AgentConversationModule: LitheModule { + public static let moduleManifest = BuiltInModuleCatalog.manifest(for: .agentConversation)! + public static let moduleContributions = BuiltInModuleCatalog.contributions(for: .agentConversation) + + public let manifest = moduleManifest + private let transportFactory: @MainActor () -> any AgentConversationTransport + private var capability: AgentConversationCapability? + private let historyPersistence: (any AgentHistoryPersisting)? + private let historyExporter: (any AgentHistoryExporting)? + + public init(transportFactory: @escaping @MainActor () -> any AgentConversationTransport, + historyPersistence: (any AgentHistoryPersisting)? = nil, + historyExporter: (any AgentHistoryExporting)? = nil) { + self.transportFactory = transportFactory + self.historyPersistence = historyPersistence + self.historyExporter = historyExporter + } + + public func activate(context: ModuleContext) async throws { + guard capability == nil else { return } + let feature = AgentConversationFeatureModel(transport: transportFactory(), workspaceURL: context.workspaceURL, + historyPersistence: historyPersistence, historyExporter: historyExporter) + context.resources.register(AgentConnectionResource(feature: feature)) + capability = AgentConversationCapability(feature: feature) + } + + public func prepareForSleep() async throws {} + public func sleep() async { await releaseFeature() } + public func shutdown() async { await releaseFeature() } + + public func exportedCapabilities() -> [ModuleCapabilityID: AnyObject] { + guard let capability else { return [:] } + return [.agentConversation: capability] + } + + public func contributions() -> [ModuleContribution] { Self.moduleContributions } + + private func releaseFeature() async { + await capability?.feature.stop() + capability = nil + } +} + +@MainActor +private final class AgentConnectionResource: ModuleResource { + let feature: AgentConversationFeatureModel + init(feature: AgentConversationFeatureModel) { self.feature = feature } + var moduleResourceKind: String { "acp-agent-connection" } + var isModuleResourceActive: Bool { feature.hasActiveConnection } + func stopModuleResource() async { await feature.stop() } +} diff --git a/macos/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift b/macos/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift index a21232b24..d0bcf87f8 100644 --- a/macos/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift +++ b/macos/Sources/LitheCoreContracts/AI/AIAssistancePorts.swift @@ -58,6 +58,14 @@ public struct AIHTTPResponse: Sendable { public protocol AIConfigurationSource: Sendable { func load() -> AIConfigurationSnapshot? + /// Read a CLI default independently of endpoint and credential availability. + func loadModel() -> AIConfigurationModel? +} + +public extension AIConfigurationSource { + func loadModel() -> AIConfigurationModel? { + load().map { AIConfigurationModel(source: $0.source, model: $0.model) } + } } public protocol CodexConfigurationSource: AIConfigurationSource {} diff --git a/macos/Sources/LitheCoreContracts/AI/CommitMessageModels.swift b/macos/Sources/LitheCoreContracts/AI/CommitMessageModels.swift index 4d8591549..df7b08930 100644 --- a/macos/Sources/LitheCoreContracts/AI/CommitMessageModels.swift +++ b/macos/Sources/LitheCoreContracts/AI/CommitMessageModels.swift @@ -484,6 +484,17 @@ public struct CommitMessageAISettings: Codable, Equatable, Sendable { } } +/// Non-secret CLI model selection, also available without an API configuration. +public struct AIConfigurationModel: Equatable, Sendable { + public let source: AIConfigurationSourceKind + public let model: String + + public init(source: AIConfigurationSourceKind, model: String) { + self.source = source + self.model = model + } +} + public struct AIConfigurationSnapshot: Identifiable, Sendable { public let source: AIConfigurationSourceKind public let providerName: String diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift new file mode 100644 index 000000000..442fc7abf --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentConversationPorts.swift @@ -0,0 +1,67 @@ +import Foundation + +/// Settings required to launch one ACP agent for a workspace. +/// +/// Phase one signs in only with a user-supplied API key; account logins +/// offered by the agent are never used. +public struct AgentLaunchConfiguration: Equatable, Sendable { + /// ACP registry id of a Lithe-installed adapter, or `nil` for `command`. + public let agentID: String? + public let command: String + public let arguments: [String] + public let workspaceURL: URL + /// Directory holding Lithe-managed adapter installs. + public let dataDirectory: URL + /// Provider protocol raw value, matching `CommitMessageAPIProtocol`. + public let providerProtocol: String + public let providerEndpoint: String + public let apiKey: String + public let providerName: String + /// Empty keeps the agent's default model. + public let model: String + public let allowsInsecureHTTP: Bool + + public init( + agentID: String?, + command: String, + arguments: [String], + workspaceURL: URL, + dataDirectory: URL, + providerProtocol: String, + providerEndpoint: String, + apiKey: String, + providerName: String, + model: String, + allowsInsecureHTTP: Bool + ) { + self.agentID = agentID + self.command = command + self.arguments = arguments + self.workspaceURL = workspaceURL + self.dataDirectory = dataDirectory + self.providerProtocol = providerProtocol + self.providerEndpoint = providerEndpoint + self.apiKey = apiKey + self.providerName = providerName + self.model = model + self.allowsInsecureHTTP = allowsInsecureHTTP + } +} + +/// One platform-owned ACP connection. Closing it must release the whole +/// agent process tree; commands and events use the JSON shapes fixed by +/// `shared/fixtures/agent/acp-events-v1.json`. +@MainActor +public protocol AgentConnection: AnyObject { + func send(commandJSON: String) throws + func close() async +} + +/// Starts a native connection only after the optional Agent module is activated. +@MainActor +public protocol AgentConversationTransport { + func open( + configuration: AgentLaunchConfiguration, + onEvent: @escaping @Sendable (String) -> Void + ) throws -> any AgentConnection +} diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentHistoryPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentHistoryPorts.swift new file mode 100644 index 000000000..58e0bedb9 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentHistoryPorts.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Lithe-owned presentation metadata; the Agent still owns the transcript. +public struct AgentHistoryMetadata: Codable, Equatable, Sendable { + public var title: String? + public var isFavorite: Bool + /// Hides a row in Lithe without deleting the Agent's original session. + public var isHidden: Bool + + public init(title: String? = nil, isFavorite: Bool = false, isHidden: Bool = false) { + self.title = title + self.isFavorite = isFavorite + self.isHidden = isHidden + } +} + +/// Platform preferences, isolated by workspace and configured Agent identity. +@MainActor +public protocol AgentHistoryPersisting { + func load(workspaceURL: URL, agentID: String) throws -> [String: AgentHistoryMetadata] + func save(_ metadata: [String: AgentHistoryMetadata], workspaceURL: URL, agentID: String) throws +} + +/// Native save dialog and file writing. A cancelled dialog returns nil. +@MainActor +public protocol AgentHistoryExporting { + func chooseDestination() -> URL? + func writeMarkdown(_ markdown: String, to url: URL) async throws +} diff --git a/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift new file mode 100644 index 000000000..5dc1932e7 --- /dev/null +++ b/macos/Sources/LitheCoreContracts/Agent/AgentManagementPorts.swift @@ -0,0 +1,191 @@ +import Foundation + +/// A tool found on the search path agents are launched with. +public struct AgentRuntimeTool: Decodable, Equatable, Sendable { + public let version: String + public let path: String + + public init(version: String, path: String) { + self.version = version + self.path = path + } +} + +/// User-installed Node.js and npm as seen by agent installs and launches. +public struct AgentRuntimeEnvironment: Decodable, Equatable, Sendable { + public let node: AgentRuntimeTool? + public let npm: AgentRuntimeTool? + /// False when the login shell reported no `PATH` and only the app's was searched. + public let usedLoginShell: Bool + + public init(node: AgentRuntimeTool?, npm: AgentRuntimeTool?, usedLoginShell: Bool) { + self.node = node + self.npm = npm + self.usedLoginShell = usedLoginShell + } +} + +/// The user's own agent CLI that an adapter drives, e.g. the Codex CLI. +public struct AgentCliStatus: Decodable, Equatable, Sendable { + public let name: String + public let command: String + public let minimumVersion: String + public let installHint: String + /// Package used when installing a missing CLI or updating a verified npm installation. + public let package: String + public let detected: AgentRuntimeTool? + public let installation: AgentCliInstallation? + + public init(name: String, command: String, minimumVersion: String, installHint: String, package: String = "", detected: AgentRuntimeTool?, installation: AgentCliInstallation? = nil) { + self.name = name + self.command = command + self.minimumVersion = minimumVersion + self.installHint = installHint + self.package = package + self.detected = detected + self.installation = installation + } +} + +/// Provenance and safe update availability reported by the native host. +public struct AgentCliInstallation: Decodable, Equatable, Sendable { + public enum Source: String, Decodable, Sendable { case npm, homebrew, native, missing, unknown } + public let source: Source + public let canUpdate: Bool + /// Display only; the host resolves the real command again when updating. + public let updateHint: String + public init(source: Source, canUpdate: Bool, updateHint: String) { + self.source = source + self.canUpdate = canUpdate + self.updateHint = updateHint + } +} + +/// One supported ACP agent and its Lithe-managed adapter install. +public struct AgentCatalogStatus: Decodable, Equatable, Identifiable, Sendable { + public let id: String + public let name: String + public let description: String + public let package: String + /// Version Lithe installs. + public let version: String + public let installedVersion: String? + /// Provider protocol raw value, matching `CommitMessageAPIProtocol`. + public let `protocol`: String + public let minimumNodeMajor: Int + public let verified: Bool + /// The user's CLI the adapter runs; `nil` when the adapter is self-contained. + public let cli: AgentCliStatus? + /// Reasons the adapter cannot be installed or started now. + public let issues: [String] + + public init( + id: String, name: String, description: String, package: String, version: String, + installedVersion: String?, protocol: String, minimumNodeMajor: Int, verified: Bool, + cli: AgentCliStatus? = nil, issues: [String] + ) { + self.id = id + self.name = name + self.description = description + self.package = package + self.version = version + self.installedVersion = installedVersion + self.protocol = `protocol` + self.minimumNodeMajor = minimumNodeMajor + self.verified = verified + self.cli = cli + self.issues = issues + } + + public var isInstalled: Bool { installedVersion != nil } + public var needsUpdate: Bool { installedVersion.map { $0 != version } ?? false } +} + +/// Result of `agent.status`, fixed by `shared/fixtures/agent/agent-management-v1.json`. +public struct AgentManagementStatus: Decodable, Equatable, Sendable { + public let environment: AgentRuntimeEnvironment + public let agents: [AgentCatalogStatus] + + public init(environment: AgentRuntimeEnvironment, agents: [AgentCatalogStatus]) { + self.environment = environment + self.agents = agents + } +} + +/// Numbers-only npm progress; total package bytes are unknown until npm finishes. +public struct AgentInstallProgress: Decodable, Equatable, Sendable { + public enum Stage: String, Decodable, Sendable { case preparing, downloading, installing, updating } + public let stage: Stage + public let downloadedBytes: UInt64 + public let bytesPerSecond: UInt64 + public let elapsedMilliseconds: UInt64 + public let idleMilliseconds: UInt64 + + public init(stage: Stage, downloadedBytes: UInt64, bytesPerSecond: UInt64, + elapsedMilliseconds: UInt64, idleMilliseconds: UInt64) { + self.stage = stage + self.downloadedBytes = downloadedBytes + self.bytesPerSecond = bytesPerSecond + self.elapsedMilliseconds = elapsedMilliseconds + self.idleMilliseconds = idleMilliseconds + } +} + +/// Verified CLI update outcome. An installer failure can remain as a warning. +public struct AgentCliUpdateResult: Decodable, Equatable, Sendable { + public let cliVersion: String + public let updaterWarning: String? + + public init(cliVersion: String, updaterWarning: String? = nil) { + self.cliVersion = cliVersion + self.updaterWarning = updaterWarning + } +} + +/// Detects the user's runtime and installs ACP adapters with the user's npm. +/// Node.js remains user-managed; CLI updates follow their verified installation owners. +public protocol AgentManagementService: Sendable { + func status(dataDirectory: URL) async throws -> AgentManagementStatus + /// Installs the pinned adapter version; returns the installed version. + func install(agentID: String, dataDirectory: URL) async throws -> String + func uninstall(agentID: String, dataDirectory: URL) async throws + /// Installs or updates the agent's own CLI through its installation manager; returns + /// the verified CLI version and any recovered installer warning. + func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult + func install(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String + func installCli(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> AgentCliUpdateResult +} + +public extension AgentManagementService { + func install(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String { + try await install(agentID: agentID, dataDirectory: dataDirectory) + } + func installCli(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> AgentCliUpdateResult { + try await installCli(agentID: agentID, dataDirectory: dataDirectory) + } +} + +/// Used where no platform adapter is composed, such as focused tests. +public struct UnavailableAgentManagementService: AgentManagementService { + public init() {} + + public func status(dataDirectory: URL) async throws -> AgentManagementStatus { + throw CocoaError(.featureUnsupported) + } + + public func install(agentID: String, dataDirectory: URL) async throws -> String { + throw CocoaError(.featureUnsupported) + } + + public func uninstall(agentID: String, dataDirectory: URL) async throws { + throw CocoaError(.featureUnsupported) + } + + public func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult { + throw CocoaError(.featureUnsupported) + } +} diff --git a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift index 125d0bff2..63bb2db64 100644 --- a/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift +++ b/macos/Sources/LitheModuleAPI/Catalog/BuiltInModuleCatalog.swift @@ -5,6 +5,15 @@ import Foundation /// scope, or lifecycle defaults. public enum BuiltInModuleCatalog { public static let manifests: [ModuleManifest] = [ + ModuleManifest( + id: .agentConversation, + displayName: "Agent Conversation", + scope: .application, + defaultState: .disabled, + activationPolicy: .onDemand, + sleepPolicy: .never, + providedCapabilities: [.agentConversation] + ), ModuleManifest( id: .aiAssistance, displayName: "AI Assistance", @@ -98,6 +107,9 @@ public enum BuiltInModuleCatalog { public static var ids: [ModuleID] { manifests.map(\.id) } public static let contributions: [ModuleID: [ModuleContribution]] = [ + .agentConversation: [ + ModuleContribution(id: "agent.conversation", kind: .toolWindow, title: "Agent", icon: "sparkles", placement: .rightSidebar, order: 200, actionID: "agent.conversation.toggle", rendererID: "agent.conversation") + ], .aiAssistance: [ ModuleContribution(id: "ai.commit-message", kind: .command, title: "Generate Commit Message", icon: "wand.and.stars"), ModuleContribution(id: "ai.pull-request-description", kind: .command, title: "Generate Pull Request Description", icon: "wand.and.stars"), @@ -178,6 +190,7 @@ public enum BuiltInPluginCatalog { private static func targetName(for id: ModuleID) -> String { switch id { case .aiAssistance: "LitheAIAssistanceModule" + case .agentConversation: "LitheAgentConversationModule" case .database: "LitheDatabaseModule" case .debug: "LitheDebugModule" case .execution: "LitheExecutionModule" diff --git a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift index 75fdd2fdd..667f10008 100644 --- a/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift +++ b/macos/Sources/LitheModuleAPI/Lifecycle/ModuleTypes.swift @@ -269,6 +269,7 @@ public extension ModuleID { static let terminal = ModuleID("dev.lithe.terminal") static let database = ModuleID("dev.lithe.database") static let aiAssistance = ModuleID("dev.lithe.ai-assistance") + static let agentConversation = ModuleID("dev.lithe.agent-conversation") static func languageServerExtension(_ languageID: String) -> ModuleID { ModuleID("dev.lithe.language.\(languageID).language-server") @@ -291,6 +292,7 @@ public extension ModuleCapabilityID { static let databaseWorkspace = ModuleCapabilityID("dev.lithe.capability.database-workspace") static let aiCommitMessage = ModuleCapabilityID("dev.lithe.capability.ai-commit-message") static let aiPullRequestDescription = ModuleCapabilityID("dev.lithe.capability.ai-pull-request-description") + static let agentConversation = ModuleCapabilityID("dev.lithe.capability.agent-conversation") static func languageServerExtension(_ languageID: String) -> ModuleCapabilityID { ModuleCapabilityID("dev.lithe.capability.language.\(languageID).language-server") diff --git a/macos/Sources/LitheRustCore/bridge.c b/macos/Sources/LitheRustCore/bridge.c index 9447e9c55..9893e6600 100644 --- a/macos/Sources/LitheRustCore/bridge.c +++ b/macos/Sources/LitheRustCore/bridge.c @@ -62,3 +62,19 @@ __attribute__((weak)) int32_t lithe_core_git_askpass(const char *prompt) { int32_t lithe_bridge_git_askpass(const char *prompt) { return lithe_core_git_askpass(prompt); } + +__attribute__((weak)) void *lithe_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context) { + (void)configuration; (void)callback; (void)context; + return NULL; +} +__attribute__((weak)) int32_t lithe_agent_send_json(void *handle, const char *command) { + (void)handle; (void)command; + return 0; +} +__attribute__((weak)) void lithe_agent_close(void *handle) { (void)handle; } + +void *lithe_bridge_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context) { + return lithe_agent_open_json(configuration, callback, context); +} +int32_t lithe_bridge_agent_send_json(void *handle, const char *command) { return lithe_agent_send_json(handle, command); } +void lithe_bridge_agent_close(void *handle) { lithe_agent_close(handle); } diff --git a/macos/Sources/LitheRustCore/include/lithe_bridge.h b/macos/Sources/LitheRustCore/include/lithe_bridge.h index c81033989..cb9b08203 100644 --- a/macos/Sources/LitheRustCore/include/lithe_bridge.h +++ b/macos/Sources/LitheRustCore/include/lithe_bridge.h @@ -14,6 +14,9 @@ char *lithe_bridge_execute_json_with_events(const char *request, void (*callback char *lithe_bridge_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_bridge_cancel(const char *operation_id); void lithe_bridge_free_string(char *value); +void *lithe_bridge_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); +int32_t lithe_bridge_agent_send_json(void *handle, const char *command); +void lithe_bridge_agent_close(void *handle); #ifdef __cplusplus } diff --git a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift index 4dcf5c9f4..8a0550d76 100644 --- a/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift +++ b/macos/Tests/LitheGitPerformanceTests/GitGraphPerformanceBaselineTests.swift @@ -7,6 +7,19 @@ import Testing @Suite("Git graph performance baseline", .serialized) struct GitGraphPerformanceBaselineTests { + @Test("Window compositor sampling requires an explicit desktop opt-in") + func compositorSamplingRequiresExplicitOptIn() { + #expect(!GitCompositorTestConfiguration.isEnabled(environment: [:])) + for value in ["", "0", "true", "yes"] { + #expect(!GitCompositorTestConfiguration.isEnabled(environment: [ + GitCompositorTestConfiguration.environmentVariable: value + ])) + } + #expect(GitCompositorTestConfiguration.isEnabled(environment: [ + GitCompositorTestConfiguration.environmentVariable: "1" + ])) + } + @Test("The synthetic history is deterministic and child-before-parent") func syntheticHistoryIsDeterministic() { let first = SyntheticGitGraphFixture.mergeHeavy(commitCount: 1_000) @@ -597,6 +610,10 @@ struct GitGraphPerformanceBaselineTests { @Test( "The native Git log keeps a window display-link cadence while scrolling", + .enabled( + if: GitCompositorTestConfiguration.isEnabled(environment: ProcessInfo.processInfo.environment), + "Set LITHE_RUN_GIT_COMPOSITOR_TESTS=1 to allow visible desktop test windows." + ), .enabled( if: ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 14, "Requires macOS 14 or newer for NSWindow display links." @@ -643,38 +660,39 @@ struct GitGraphPerformanceBaselineTests { width: 900, height: 520 ), - styleMask: [.borderless], + styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.isReleasedWhenClosed = false - window.level = .floating - window.ignoresMouseEvents = true - window.backgroundColor = .clear + defer { + window.orderOut(nil) + window.close() + } + window.title = "Lithe Git log compositor test" + window.level = .normal + window.isOpaque = true + window.backgroundColor = .windowBackgroundColor window.contentView = scrollView let application = NSApplication.shared + let previousActivationPolicy = application.activationPolicy() + defer { application.setActivationPolicy(previousActivationPolicy) } application.setActivationPolicy(.accessory) application.finishLaunching() - application.activate(ignoringOtherApps: true) - window.orderFrontRegardless() - window.makeKey() + window.orderFront(nil) window.displayIfNeeded() guard window.isVisible, window.screen != nil else { print("Window compositor sample skipped: test window is not attached to a display.") - window.orderOut(nil) - window.close() return } - defer { - window.orderOut(nil) - window.close() - } let sampler = WindowCompositorFrameSampler( window: window, scrollView: scrollView, targetFrameCount: 45 ) + // A timeout must also invalidate the display link's retained target. + defer { sampler.stop() } sampler.start() let firstSampleDeadline = CACurrentMediaTime() + 0.5 @@ -682,7 +700,6 @@ struct GitGraphPerformanceBaselineTests { RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) } guard sampler.sampleCount > 0 else { - sampler.stop() print("Window compositor sample skipped: WindowServer did not deliver a display-link callback.") return } @@ -709,6 +726,10 @@ struct GitGraphPerformanceBaselineTests { @Test( "The native Worktree rows keep a window display-link cadence while scrolling", + .enabled( + if: GitCompositorTestConfiguration.isEnabled(environment: ProcessInfo.processInfo.environment), + "Set LITHE_RUN_GIT_COMPOSITOR_TESTS=1 to allow visible desktop test windows." + ), .enabled( if: ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 14, "Requires macOS 14 or newer for NSWindow display links." @@ -743,38 +764,39 @@ struct GitGraphPerformanceBaselineTests { width: 900, height: 520 ), - styleMask: [.borderless], + styleMask: [.titled, .closable], backing: .buffered, defer: false ) window.isReleasedWhenClosed = false - window.level = .floating - window.ignoresMouseEvents = true - window.backgroundColor = .clear + defer { + window.orderOut(nil) + window.close() + } + window.title = "Lithe Worktree compositor test" + window.level = .normal + window.isOpaque = true + window.backgroundColor = .windowBackgroundColor window.contentView = scrollView let application = NSApplication.shared + let previousActivationPolicy = application.activationPolicy() + defer { application.setActivationPolicy(previousActivationPolicy) } application.setActivationPolicy(.accessory) application.finishLaunching() - application.activate(ignoringOtherApps: true) - window.orderFrontRegardless() - window.makeKey() + window.orderFront(nil) window.displayIfNeeded() guard window.isVisible, window.screen != nil else { print("Worktree window compositor sample skipped: test window is not attached to a display.") - window.orderOut(nil) - window.close() return } - defer { - window.orderOut(nil) - window.close() - } let sampler = WindowCompositorFrameSampler( window: window, scrollView: scrollView, targetFrameCount: 45 ) + // A timeout must also invalidate the display link's retained target. + defer { sampler.stop() } sampler.start() let firstSampleDeadline = CACurrentMediaTime() + 0.5 @@ -782,7 +804,6 @@ struct GitGraphPerformanceBaselineTests { RunLoop.main.run(mode: .default, before: Date(timeIntervalSinceNow: 0.05)) } guard sampler.sampleCount > 0 else { - sampler.stop() print("Worktree window compositor sample skipped: WindowServer did not deliver a display-link callback.") return } @@ -832,6 +853,14 @@ private func makeWorktreeListFixtureItem( return GitWorktreeListItem(worktree: worktree, status: status) } +private enum GitCompositorTestConfiguration { + static let environmentVariable = "LITHE_RUN_GIT_COMPOSITOR_TESTS" + + static func isEnabled(environment: [String: String]) -> Bool { + environment[environmentVariable] == "1" + } +} + private struct TestTimeoutError: Error, CustomStringConvertible { let message: String diff --git a/macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift b/macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift new file mode 100644 index 000000000..dc41df676 --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift @@ -0,0 +1,54 @@ +import LitheApplicationKernel +import LitheModuleAPI +import Testing +@testable import Lithe + +@MainActor +struct AgentConversationEntryPolicyTests { + @Test + func disabledAgentKeepsItsSetupEntryAcrossEnableAndDisableWithoutConstruction() async throws { + let runtime = ModuleRuntime() + var factoryCalls = 0 + let manifest = try #require(BuiltInModuleCatalog.manifest(for: .agentConversation)) + #expect(manifest.defaultState == .disabled) + try runtime.register(ModuleFactory( + manifest: manifest, + contributions: BuiltInModuleCatalog.contributions(for: .agentConversation) + ) { + factoryCalls += 1 + throw UnexpectedModuleConstruction() + }) + + for enabled in [false, true, false] { + try await runtime.setEnabled(enabled, for: .agentConversation) + let entries = AgentConversationEntryPolicy.rightSidebarContributions( + from: runtime.availableContributions().values.flatMap { $0 } + ) + let agent = try #require(entries.first { $0.id == "agent.conversation" }) + #expect(entries.count == 1) + #expect(agent.actionID == "agent.conversation.toggle") + #expect(agent.rendererID == "agent.conversation") + try WorkbenchModuleUIComposition.builtIn.validate(contributions: entries) + #expect(try runtime.snapshot(for: .agentConversation).isInstantiated == false) + #expect(runtime.capability(.agentConversation) == nil) + #expect(factoryCalls == 0) + } + await runtime.shutdownAll() + } + + @Test + func agentEntryDoesNotExposeOtherDisabledModulesOrDuplicateEnabledTools() throws { + let execution = BuiltInModuleCatalog.contributions(for: .execution) + let enabled = execution + BuiltInModuleCatalog.contributions(for: .agentConversation) + let entries = AgentConversationEntryPolicy.rightSidebarContributions(from: enabled) + #expect(entries.filter { $0.id == "agent.conversation" }.count == 1) + #expect(entries.contains { $0.id == "execution.maven" }) + #expect(!entries.contains { $0.id == "execution.maven.output" }) + #expect(entries.allSatisfy { $0.placement == .rightSidebar }) + let disabled = AgentConversationEntryPolicy.rightSidebarContributions(from: []) + #expect(disabled.map(\.id) == ["agent.conversation"]) + try WorkbenchModuleUIComposition.builtIn.validate(contributions: entries) + } +} + +private struct UnexpectedModuleConstruction: Error {} diff --git a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift new file mode 100644 index 000000000..1085f6542 --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift @@ -0,0 +1,683 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import LitheAgentConversationModule + +/// Drives the feature model with events from the shared Rust fixture. Events +/// are delivered synchronously through `receive`, so no test waits on timers. +@MainActor +struct AgentConversationFeatureModelTests { + @Test + func contextUsageTracksEachSessionAndCompactionWithoutAccumulatingTokens() async throws { + try await withContextFeature { feature, connection in + #expect(feature.selectedConversation?.contextUsage == nil) + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any])) + try feature.receive(event("usageUpdate")) + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 18700) + #expect(feature.selectedConversation?.contextUsage?.capacityTokens == 258400) + feature.startNewConversation() + #expect(feature.selectedConversation?.contextUsage == nil) + try feature.receive(event("sessionCreated", ["sessionId": "session-2", "token": connection.commands.last?["token"] as Any])) + try feature.receive(event("usageUpdate", ["sessionId": "session-2", + "update": ["sessionUpdate": "usage_update", "used": 90000, "size": 100000]])) + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 90000) + feature.selectSession("session-1") + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 18700) + try feature.receive(event("usageUpdate", ["update": ["sessionUpdate": "usage_update", "used": 1000, "size": 258400]])) + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 1000) + #expect(feature.conversations["session-2"]?.contextUsage?.usedTokens == 90000) + #expect(feature.selectedConversation?.messages.isEmpty == true) + feature.closeConversation("session-1") + #expect(feature.conversations["session-1"] == nil) + } + } + + @Test + func missingInvalidAndDisconnectedUsageRemainUnknown() async throws { + try await withContextFeature { feature, connection in + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any])) + for payload: [String: Any] in [ + ["used": -1, "size": 100], ["used": 1.5, "size": 100], + ["used": true, "size": 100], ["used": "1", "size": 100], + ["used": 1, "size": 0], ["used": 1], ["size": 100] + ] { + try feature.receive(event("usageUpdate")) + var update = payload + update["sessionUpdate"] = "usage_update" + try feature.receive(event("usageUpdate", ["update": update])) + #expect(feature.selectedConversation?.contextUsage == nil) + } + try feature.receive(event("usageUpdate", ["update": ["sessionUpdate": "usage_update", "used": 0, "size": 100]])) + #expect(feature.selectedConversation?.contextUsage?.fraction == 0) + try feature.receive(event("usageUpdate", ["update": ["sessionUpdate": "usage_update", "used": 150, "size": 100]])) + #expect(feature.selectedConversation?.contextUsage?.fraction == 1.5) + try feature.receive(event("stopped")) + #expect(feature.selectedConversation?.contextUsage == nil) + try feature.receive(event("usageUpdate")) + #expect(feature.selectedConversation?.contextUsage == nil) + } + } + + @Test + func confirmedModelChangesInvalidateCapacityButOtherOptionsKeepUsage() async throws { + try await withContextFeature { feature, connection in + feature.prepareConversation() + let option = { (model: String, permission: String) -> [[String: Any]] in [ + ["id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": model, + "options": [["value": "model-a", "name": "A"], ["value": "model-b", "name": "B"]]], + ["id": "mode", "name": "Permissions", "category": "mode", "type": "select", "currentValue": permission, + "options": [["value": "read-only", "name": "Read Only"], ["value": "auto", "name": "Auto"]]] + ] } + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "configOptions": option("model-a", "read-only")])) + try feature.receive(event("usageUpdate")) + feature.setConfigOption("mode", value: "auto") + try feature.receive(event("sessionConfigured", ["token": connection.commands.last?["token"] as Any, + "configOptions": option("model-a", "auto")])) + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 18700) + feature.setConfigOption("model", value: "model-b") + #expect(feature.selectedConversation?.contextUsage?.usedTokens == 18700) + try feature.receive(event("sessionConfigured", ["token": connection.commands.last?["token"] as Any, + "configOptions": option("model-b", "auto")])) + #expect(feature.selectedConversation?.contextUsage == nil) + try feature.receive(event("usageUpdate")) + try feature.receive(event("usageUpdate", ["update": ["sessionUpdate": "config_option_update", + "configOptions": option("model-a", "auto")]])) + #expect(feature.selectedConversation?.contextUsage == nil) + } + } + + private func withContextFeature(_ operation: (AgentConnectionModel, TestAgentConnection) throws -> Void) async throws { + let (feature, connection) = try connectedFeature() + do { try operation(feature, connection) } + catch { await feature.stop(); throw error } + await feature.stop() + } + + @Test + func readyAgentListsWorkspaceHistory() throws { + let (feature, connection) = try connectedFeature() + #expect(feature.connectionState == .ready) + #expect(feature.canLoadSessions) + #expect(connection.commands.last?["kind"] as? String == "listSessions") + + try feature.receive(event("sessions", ["token": connection.commands.last?["token"] as Any])) + #expect(feature.sessions.map(\.id) == ["session-1", "session-2"]) + #expect(feature.sessions.first?.title == "Explain this project") + } + + @Test + func firstMessageCreatesASessionThenPromptsIt() throws { + let (feature, connection) = try connectedFeature() + try feature.send("Explain this project") + let create = try #require(connection.commands.last) + #expect(create["kind"] as? String == "newSession") + #expect(feature.pendingNewConversationPrompt == "Explain this project") + + try feature.receive(event("sessionCreated", ["token": create["token"] as Any])) + #expect(feature.selectedSessionID == "session-1") + #expect(feature.pendingNewConversationPrompt == nil) + let prompt = try #require(connection.commands.last) + #expect(prompt["kind"] as? String == "prompt") + #expect(prompt["sessionId"] as? String == "session-1") + #expect(prompt["text"] as? String == "Explain this project") + #expect(feature.selectedConversation?.isResponding == true) + #expect(feature.sessions.first?.title == "Explain this project") + } + + @Test + func streamedTextAndToolUpdatesBuildOneTranscript() throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("agentMessageChunk")) + try feature.receive(event("toolCall")) + try feature.receive(event("toolCallUpdate")) + try feature.receive(event("sessionInfo")) + try feature.receive(event("turnFinished")) + + let messages = try #require(feature.selectedConversation?.messages) + #expect(messages.map(\.role) == [.user, .agent, .tool]) + #expect(messages[1].text == "This project **builds** an IDE.") + // The update carries only a status, so the tool keeps its title. + #expect(messages[2].text == "Run tests") + #expect(messages[2].toolStatus == .completed) + #expect(messages[2].toolDetails.input?.contains("node --test") == true) + #expect(messages[2].toolDetails.output?.contains("exitCode") == true) + #expect(messages[2].toolDetails.locations.first?.line == 1) + #expect(messages[2].toolDetails.content.first?.text == "1 test passed") + #expect(feature.selectedConversation?.isResponding == false) + #expect(feature.sessions.first?.title == "Project overview") + } + + @Test + func permissionChoicesAreAnsweredOrRejectedByCancel() throws { + let (feature, connection) = try respondingFeature() + var attention: [Bool] = [] + feature.onAttentionChanged = { attention.append($0) } + + try feature.receive(event("permission")) + #expect(feature.selectedConversation?.permission?.details.input?.contains("node --test") == true) + #expect(feature.selectedConversation?.permission?.choices.map(\.id) == ["allow_once", "reject_once"]) + feature.answerPermission(optionID: "allow_once") + let answer = try #require(connection.commands.last) + #expect(answer["kind"] as? String == "permission") + #expect(answer["requestId"] as? String == "permission-1") + #expect(answer["optionId"] as? String == "allow_once") + + try feature.receive(event("permission")) + feature.answerPermission(optionID: nil) + #expect(connection.commands.last?["optionId"] is NSNull) + + try feature.receive(event("permission")) + feature.cancel() + #expect(feature.selectedConversation?.permission == nil) + #expect(connection.commands.last?["kind"] as? String == "cancel") + #expect(feature.selectedConversation?.isCancelling == true) + let count = connection.commands.count + try feature.receive(event("permission")) + #expect(feature.selectedConversation?.permission == nil) + try feature.receive(event("toolCall")) + feature.cancel() + #expect(throws: AgentConversationError.sessionBusy) { try feature.send("Must not enter the stopping turn") } + #expect(connection.commands.count == count) + try feature.receive(event("turnCancelled")) + #expect(feature.selectedConversation?.isResponding == false) + #expect(feature.selectedConversation?.isCancelling == false) + #expect(feature.selectedConversation?.messages.last?.toolStatus == .interrupted) + #expect(attention == [true, false, true, false, true, false]) + } + + @Test + func openingAnEarlierSessionReplaysItsHistoryBeforePrompting() throws { + let (feature, connection) = try connectedFeature() + try feature.receive(event("sessions", ["token": connection.commands.last?["token"] as Any])) + feature.selectSession("session-1") + let load = try #require(connection.commands.last) + #expect(load["kind"] as? String == "loadSession") + #expect(feature.selectedConversation?.isLoading == true) + + try feature.send("Continue") + #expect(connection.commands.count == 2, "prompt waits for the load") + try feature.receive(event("userMessageChunk")) + try feature.receive(event("agentMessageChunk")) + try feature.receive(event("sessionLoaded", ["token": load["token"] as Any])) + + let conversation = try #require(feature.selectedConversation) + #expect(conversation.isAttached) + #expect(conversation.messages.map(\.text) == ["Explain this project", "This project **builds** an IDE.", "Continue"]) + #expect(connection.commands.last?["kind"] as? String == "prompt") + } + + @Test + func openedConversationsBecomeTabsAndClosingOneFallsBackToTheLastOpenTab() throws { + let (feature, connection) = try connectedFeature() + try feature.receive(event("sessions", ["token": connection.commands.last?["token"] as Any])) + #expect(feature.openSessionIDs.isEmpty, "history is not opened until selected") + + feature.selectSession("session-2") + try feature.receive(event("sessionLoaded", ["token": connection.commands.last?["token"] as Any, "sessionId": "session-2"])) + feature.startNewConversation() + try feature.send("Explain this project") + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any])) + #expect(feature.openSessionIDs == ["session-2", "session-1"]) + + // A responding conversation keeps its tab so the reply is not lost. + feature.closeConversation("session-1") + #expect(feature.openSessionIDs == ["session-2", "session-1"]) + try feature.receive(event("turnFinished")) + feature.closeConversation("session-1") + #expect(feature.openSessionIDs == ["session-2"]) + #expect(feature.selectedSessionID == "session-2") + #expect(feature.conversations["session-1"] == nil) + + feature.closeConversation("session-2") + #expect(feature.selectedSessionID == nil, "closing the last tab starts a new conversation") + } + + @Test + func agentExitKeepsTranscriptAndRequiresReloadAfterReconnect() async throws { + let transport = TestAgentTransport() + let feature = AgentConnectionModel(transport: transport) + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + try feature.send("Explain this project") + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + + try feature.receive(event("stopped")) + #expect(feature.connectionState == .failed("The Agent connection closed unexpectedly")) + #expect(feature.selectedConversation?.isResponding == false) + #expect(feature.selectedConversation?.isAttached == false) + #expect(feature.selectedConversation?.messages.count == 1) + #expect(throws: AgentConversationError.notConnected) { try feature.send("again") } + + await feature.stop() + #expect(transport.connections[0].closeCount == 1) + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + try feature.send("again") + #expect(transport.connections[1].commands.last?["kind"] as? String == "loadSession") + await feature.stop() + #expect(transport.connections[1].closeCount == 1) + } + + @Test + func providerReconnectRecreatesUnpromptedSessionsAndKeepsHistoryTabs() async throws { + try await withReconnectableFeature { feature, transport in + feature.selectSession("session-2") + try feature.receive(event("sessionLoaded", ["token": transport.connections[0].commands.last?["token"] as Any, + "sessionId": "session-2"])) + for id in ["empty-1", "empty-2"] { + feature.startNewConversation() + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any, + "sessionId": id])) + } + await feature.stop() + #expect(feature.selectedSessionID == nil) + #expect(feature.openSessionIDs == ["session-2"]) + #expect(feature.conversations["empty-1"] == nil && feature.conversations["empty-2"] == nil) + #expect(!feature.sessions.contains { $0.id.hasPrefix("empty-") }) + #expect(feature.conversations["session-2"] != nil, "loaded history is retained even if its replay was empty") + + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + feature.prepareConversation() + let connection = transport.connections[1] + let create = try #require(connection.commands.last) + #expect(create["kind"] as? String == "newSession") + #expect(!connection.commands.contains { $0["kind"] as? String == "loadSession" }) + let file = try AgentFileReference(url: URL(fileURLWithPath: "/example/project/notes.txt")) + try feature.send("Continue with the new provider", files: [file]) + try feature.receive(event("sessionCreated", ["token": create["token"] as Any, "sessionId": "replacement"])) + let prompts = connection.commands.filter { $0["kind"] as? String == "prompt" } + #expect(prompts.count == 1) + #expect(prompts.first?["sessionId"] as? String == "replacement") + #expect((prompts.first?["files"] as? [[String: String]])?.first?["uri"] == file.id) + } + } + + @Test + func agentExitDropsOnlyItsUnpersistedEmptySession() async throws { + try await withReconnectableFeature { feature, transport in + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("stopped")) + #expect(feature.selectedSessionID == nil) + #expect(feature.openSessionIDs.isEmpty && feature.sessions.isEmpty) + await feature.stop() + #expect(transport.connections[0].closeCount == 1) + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + feature.prepareConversation() + #expect(transport.connections[1].commands.last?["kind"] as? String == "newSession") + } + } + + @Test + func emptySessionConfirmedByUpstreamHistoryStillLoadsAfterReconnect() async throws { + try await withReconnectableFeature { feature, transport in + let list = try #require(transport.connections[0].commands.last) + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("sessions", ["token": list["token"] as Any])) + await feature.stop() + #expect(feature.selectedSessionID == "session-1") + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + feature.prepareConversation() + #expect(transport.connections[1].commands.last?["kind"] as? String == "loadSession") + #expect(transport.connections[1].commands.last?["sessionId"] as? String == "session-1") + } + } + + @Test + func missingHistoryReportsItsLoadFailureWithoutCreatingAReplacement() async throws { + try await withReconnectableFeature { feature, transport in + try feature.receive(event("sessions", ["token": transport.connections[0].commands.last?["token"] as Any])) + feature.selectSession("session-2") + let connection = transport.connections[0] + let load = try #require(connection.commands.last) + let count = connection.commands.count + try feature.receive(event("requestFailed", ["token": load["token"] as Any, + "sessionId": "session-2", "message": "no rollout found for thread id session-2"])) + #expect(feature.selectedSessionID == "session-2") + #expect(feature.selectedConversation?.errorMessage == "no rollout found for thread id session-2") + #expect(connection.commands.count == count, "a missing history file is not an invitation to replace a real conversation") + await feature.stop() + #expect(feature.openSessionIDs == ["session-2"]) + #expect(feature.sessions.contains { $0.id == "session-2" }) + } + } + + @Test + func failedFirstSendDoesNotMakeAnEmptySessionResumable() async throws { + try await withReconnectableFeature { feature, transport in + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + transport.connections[0].sendFailure = .notConnected + #expect(throws: AgentConversationError.sendFailed(AgentConversationError.notConnected.localizedDescription)) { + try feature.send("Not delivered") + } + await feature.stop() + #expect(feature.selectedSessionID == nil) + #expect(feature.conversations.isEmpty) + } + } + + @Test + func unsolicitedTranscriptIsRetainedEvenBeforeFirstLocalPrompt() async throws { + try await withReconnectableFeature { feature, transport in + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("agentMessageChunk")) + await feature.stop() + #expect(feature.selectedSessionID == "session-1") + #expect(feature.selectedConversation?.messages.first?.text == "This project **builds** an IDE.") + } + } + + @Test + func requestFailureEndsTheTurnWithoutStoppingTheConnection() throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("requestFailed")) + #expect(feature.selectedConversation?.isResponding == false) + #expect(feature.selectedConversation?.errorMessage == "The Agent is still responding in this conversation") + #expect(feature.connectionState == .ready) + } + + @Test + func managementStatusFixtureDecodesIntoContractTypes() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/agent-management-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let responses = try #require(fixture["responses"] as? [String: Any]) + let status = try JSONDecoder().decode( + AgentManagementStatus.self, + from: JSONSerialization.data(withJSONObject: try #require(responses["status"])) + ) + #expect(status.environment.node?.version == "20.11.0") + let codex = try #require(status.agents.first) + #expect(codex.isInstalled && !codex.needsUpdate) + #expect(codex.cli?.detected?.version == "0.156.1") + #expect(codex.cli?.minimumVersion == "0.156.0") + #expect(codex.cli?.installation?.source == .npm) + #expect(codex.cli?.installation?.canUpdate == true) + let claude = try #require(status.agents.last) + #expect(claude.cli?.command == "claude") + #expect(claude.cli?.detected == nil) + #expect(!claude.isInstalled) + #expect(claude.issues.count == 2) + } + + // MARK: Helpers + + @Test + func concurrentPermissionsArePresentedInOrderAndCancelClearsTheQueue() throws { + let (feature, connection) = try respondingFeature() + try feature.receive(event("permission")) + try feature.receive(event("permission", ["requestId": "permission-2"])) + #expect(feature.selectedConversation?.permission?.id == "permission-1") + feature.answerPermission(optionID: "allow_once") + #expect(connection.commands.last?["requestId"] as? String == "permission-1") + #expect(feature.selectedConversation?.permission?.id == "permission-2") + #expect(feature.hasPendingPermission) + feature.cancel() + #expect(!feature.hasPendingPermission) + } + + @Test + func configurationIsAvailableBeforeFirstPromptAndOnlyChangesOnAcknowledgement() throws { + let (feature, connection) = try connectedFeature() + feature.prepareConversation() + let token = try #require(connection.commands.last?["token"]) + let configured = try #require(JSONSerialization.jsonObject(with: Data(event("sessionConfigured").utf8)) as? [String: Any]) + try feature.receive(event("sessionCreated", ["token": token, "configOptions": configured["configOptions"] as Any])) + #expect(feature.selectedConversation?.messages.isEmpty == true) + #expect(feature.selectedConversation?.configOptions.map(\.category) == ["model", "mode", "thought_level"]) + feature.setConfigOption("reasoning_effort", value: "high") + let command = try #require(connection.commands.last) + #expect(command["kind"] as? String == "setConfigOption") + #expect(command["value"] as? String == "high") + #expect(feature.selectedConversation?.configOptions.last?.currentValue == "medium") + #expect(throws: AgentConversationError.configurationPending) { try feature.send("Wait") } + try feature.receive(event("sessionConfigured", ["token": "stale"])) + #expect(feature.selectedConversation?.pendingConfigToken != nil) + try feature.receive(event("requestFailed", ["token": command["token"] as Any, "message": "Unsupported value"])) + #expect(feature.selectedConversation?.pendingConfigToken == nil) + #expect(feature.selectedConversation?.configurationError == "Unsupported value") + #expect(feature.selectedConversation?.configOptions.last?.currentValue == "medium") + feature.setConfigOption("reasoning_effort", value: "high") + var options = try #require(configured["configOptions"] as? [[String: Any]]) + options[2]["currentValue"] = "high" + try feature.receive(event("sessionConfigured", ["token": connection.commands.last?["token"] as Any, "configOptions": options])) + #expect(feature.selectedConversation?.configOptions.last?.currentValue == "high") + #expect(feature.selectedConversation?.pendingConfigToken == nil) + try feature.send("Now send") + #expect(connection.commands.last?["kind"] as? String == "prompt") + } + + @Test + func failedHistoryReloadRestoresTranscriptAndDropsPartialReplay() async throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("turnFinished")) + let messages = feature.selectedConversation?.messages + await feature.stop() + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + feature.selectSession("session-1") + try feature.receive(event("agentMessageChunk")) + // list=1, create=2, reconnect list=3, load=4 + try feature.receive(event("requestFailed", ["token": "lithe-4", "message": "Load failed"])) + #expect(feature.selectedConversation?.messages == messages) + #expect(feature.selectedConversation?.isLoading == false) + #expect(feature.selectedConversation?.errorMessage == "Load failed") + await feature.stop() + #expect(feature.selectedConversation?.messages == messages) + } + + @Test + func groupedConfigChoicesAndPartialToolUpdatesPreserveUpstreamData() { + let options = AgentSessionConfigOption.parse([[ + "id": "model", "name": "Model", "type": "select", "currentValue": "a", + "options": [["name": "Provider", "options": [["value": "a", "name": "Model A", "description": "Upstream choice detail"]]]] + ]]) + #expect(options.first?.choices.first?.group == "Provider") + #expect(options.first?.choices.first?.description == "Upstream choice detail") + #expect(options.first?.currentLabel == "Model A") + var details = AgentToolDetails() + details.merge(["kind": "edit", "rawInput": ["path": "main.js"], + "content": [["type": "diff", "path": "main.js", "oldText": "before", "newText": "after"]]]) + details.merge(["status": "completed"]) + #expect(details.kind == "edit") + #expect(details.content.first?.text == "---\nbefore\n+++\nafter") + details.merge(["rawOutput": String(repeating: "x", count: 100_000)]) + #expect((details.output?.count ?? 0) < 33_000) + details.merge(["rawInput": NSNull(), "content": []]) + #expect(details.input == nil && details.content.isEmpty) + let root = URL(fileURLWithPath: "/tmp/example-project", isDirectory: true) + #expect(AgentToolDetails.Location(path: "main.js", line: 1).fileURL(in: root)?.path == "/tmp/example-project/main.js") + #expect(AgentToolDetails.Location(path: "../outside.js", line: nil).fileURL(in: root) == nil) + #expect(AgentToolDetails.Location(path: "/tmp/example-project-other/main.js", line: nil).fileURL(in: root) == nil) + } + + private var configuration: AgentLaunchConfiguration { + AgentLaunchConfiguration( + agentID: "codex-acp", + command: "", + arguments: [], + workspaceURL: URL(fileURLWithPath: "/tmp/lithe-acp-test"), + dataDirectory: URL(fileURLWithPath: "/tmp/lithe-acp-data"), + providerProtocol: "responses", + providerEndpoint: "https://gateway.example.com/v1", + apiKey: "test-key", + providerName: "Example", + model: "", + allowsInsecureHTTP: false + ) + } + + @Test + func panelKeepsOneLazyConnectionPerAgentAndAggregatesAttention() async throws { + let transport = TestAgentTransport() + let panel = AgentConversationFeatureModel(transport: transport) + var attention: [Bool] = [] + panel.onAttentionChanged = { attention.append($0) } + #expect(panel.selectedConnection == nil) + + panel.setAgents([AgentOption(id: "codex-acp", name: "Codex"), AgentOption(id: "claude-acp", name: "Claude")]) + #expect(panel.selectedAgentID == "codex-acp") + #expect(transport.connections.isEmpty, "selecting an agent starts nothing") + let codex = try #require(panel.selectedConnection) + #expect(panel.connection(for: "codex-acp") === codex) + + try codex.connect(configuration: configuration) + try codex.receive(event("ready")) + try codex.send("Explain this project") + try codex.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try codex.receive(event("permission")) + #expect(attention == [true]) + + panel.selectAgent("claude-acp") + let claude = try #require(panel.selectedConnection) + #expect(claude !== codex) + panel.selectAgent("unknown") + #expect(panel.selectedAgentID == "claude-acp") + codex.cancel() + #expect(attention == [true, false]) + + panel.setAgents([AgentOption(id: "codex-acp", name: "Codex")]) + #expect(panel.selectedAgentID == "codex-acp", "a removed agent falls back to the first one") + #expect(panel.hasActiveConnection) + await panel.stop() + #expect(!panel.hasActiveConnection) + #expect(transport.connections[0].closeCount == 1) + } + + @Test + func droppedFilesStayWithFirstMessageUntilSessionCreation() throws { + let (feature, connection) = try connectedFeature() + let files = try AgentFileReference.adding([ + URL(fileURLWithPath: "/example/project/中文 File.swift"), + URL(fileURLWithPath: "/example/project/README.md") + ], to: []) + try feature.send("Explain these files", files: files) + let create = try #require(connection.commands.last) + #expect(create["kind"] as? String == "newSession") + #expect(feature.pendingNewConversationPrompt?.contains("中文 File.swift") == true) + try feature.receive(event("sessionCreated", ["token": create["token"] as Any])) + let prompt = try #require(connection.commands.last) + let references = try #require(prompt["files"] as? [[String: String]]) + #expect(references.map { $0["uri"] } == files.map { Optional($0.id) }) + #expect(references.map { $0["name"] } == ["中文 File.swift", "README.md"]) + #expect(feature.selectedConversation?.messages.last?.text.contains("README.md") == true) + } + + @Test + func fileOnlyMessageWaitsForHistoryLoadAndKeepsItsSession() throws { + let (feature, connection) = try connectedFeature() + feature.selectSession("session-2") + let load = try #require(connection.commands.last) + let file = try AgentFileReference(url: URL(fileURLWithPath: "/example/project/notes.txt")) + try feature.send(" ", files: [file]) + feature.selectSession("session-1") + try feature.receive(event("sessionLoaded", ["token": load["token"] as Any, "sessionId": "session-2"])) + let prompt = try #require(connection.commands.last) + #expect(prompt["sessionId"] as? String == "session-2") + #expect(prompt["text"] as? String == "") + #expect((prompt["files"] as? [[String: String]])?.first?["uri"] == file.id) + #expect(feature.selectedSessionID == "session-1") + } + + @Test + func failedSendCanRetryTheSameFilesWithoutDuplicatingTranscript() throws { + let (feature, connection) = try connectedFeature() + feature.prepareConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any])) + let files = try AgentFileReference.adding([URL(fileURLWithPath: "/example/project/notes.txt")], to: []) + connection.sendFailure = AgentConversationError.notConnected + #expect(throws: AgentConversationError.sendFailed(AgentConversationError.notConnected.localizedDescription)) { + try feature.send("Read this", files: files) + } + #expect(feature.selectedConversation?.messages.isEmpty == true) + #expect(feature.selectedConversation?.isResponding == false) + connection.sendFailure = nil + try feature.send("Read this", files: files) + #expect(feature.selectedConversation?.messages.count == 1) + #expect((connection.commands.last?["files"] as? [[String: String]])?.count == 1) + } + + private func connectedFeature() throws -> (AgentConnectionModel, TestAgentConnection) { + let transport = TestAgentTransport() + let feature = AgentConnectionModel(transport: transport) + try feature.connect(configuration: configuration) + #expect(feature.connectionState == .connecting) + try feature.receive(event("ready")) + return (feature, transport.connections[0]) + } + + private func withReconnectableFeature( + _ run: (AgentConnectionModel, TestAgentTransport) async throws -> Void + ) async throws { + let transport = TestAgentTransport() + let feature = AgentConnectionModel(transport: transport) + do { + try feature.connect(configuration: configuration) + try feature.receive(event("ready")) + try await run(feature, transport) + await feature.stop() + } catch { + await feature.stop() + throw error + } + } + + private func respondingFeature() throws -> (AgentConnectionModel, TestAgentConnection) { + let (feature, connection) = try connectedFeature() + try feature.send("Explain this project") + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any])) + return (feature, connection) + } + + private func event(_ name: String, _ overrides: [String: Any] = [:]) throws -> String { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/acp-events-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let events = try #require(fixture["events"] as? [String: Any]) + var event = try #require(events[name] as? [String: Any]) + event.merge(overrides) { _, new in new } + return String(decoding: try JSONSerialization.data(withJSONObject: event), as: UTF8.self) + } +} + +@MainActor +private final class TestAgentTransport: AgentConversationTransport { + var connections: [TestAgentConnection] = [] + + func open( + configuration: AgentLaunchConfiguration, + onEvent: @escaping @Sendable (String) -> Void + ) throws -> any AgentConnection { + let connection = TestAgentConnection() + connections.append(connection) + return connection + } +} + +@MainActor +private final class TestAgentConnection: AgentConnection { + var commands: [[String: Any]] = [] + var closeCount = 0 + var sendFailure: AgentConversationError? + + func send(commandJSON: String) throws { + if let sendFailure { throw sendFailure } + let object = try JSONSerialization.jsonObject(with: Data(commandJSON.utf8)) + commands.append(try #require(object as? [String: Any])) + } + + func close() async { closeCount += 1 } +} diff --git a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift new file mode 100644 index 000000000..12e78d52d --- /dev/null +++ b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift @@ -0,0 +1,121 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Agent conversation presentation") +struct AgentConversationPresentationTests { + @Test + func contextIndicatorUsesZeroPlaceholderAndReportedCountsWhenInUse() throws { + let locale = Locale(identifier: "en_US") + let usage = try #require(AgentContextUsage(usedTokens: 18700, capacityTokens: 258400)) + #expect(AgentContextUsagePresentation.percentage(nil, locale: locale) == "0%") + #expect(AgentContextUsagePresentation.details(nil, locale: locale).contains("0.0%")) + #expect(!AgentContextUsagePresentation.details(nil, locale: locale).contains("/")) + #expect(AgentContextUsagePresentation.percentage(usage, locale: locale) == "7%") + #expect(AgentContextUsagePresentation.details(usage, locale: locale).contains("7.2% · 18.7k / 258.4k")) + let zero = try #require(AgentContextUsage(usedTokens: 0, capacityTokens: 100)) + #expect(AgentContextUsagePresentation.percentage(zero, locale: locale) == "0%") + #expect(AgentContextUsagePresentation.details(zero, locale: locale) == AgentContextUsagePresentation.details(nil, locale: locale)) + let over = try #require(AgentContextUsage(usedTokens: 150, capacityTokens: 100)) + #expect(AgentContextUsagePresentation.percentage(over, locale: locale) == "150%") + #expect(AgentContextUsagePresentation.details(over, locale: locale).contains("150 / 100")) + let largest = try #require(AgentContextUsage(usedTokens: .max, capacityTokens: 1)) + #expect(largest.fraction.isFinite) + #expect(AgentContextUsage(usedTokens: 0, capacityTokens: 0) == nil) + } + + @Test + func modelSearchUsesUpstreamNamesIDsAndGroupsWithoutChangingSelection() throws { + let option = try #require(AgentSessionConfigOption.parse([[ + "id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": "model-b", + "options": [["name": "Provider", "options": [["value": "model-a", "name": "Alpha"], + ["value": "model-b", "name": "Béta"]]]] + ]]).first) + let filter = { AgentSessionSelectorPresentation.filteredChoices(option, query: $0).map(\.id) } + #expect(filter(" ") == ["model-a", "model-b"]) + #expect(filter("alpha") == ["model-a"]) + #expect(filter("MODEL-B") == ["model-b"]) + #expect(filter("beta") == ["model-b"]) + #expect(filter("Provider") == ["model-a", "model-b"]) + #expect(filter("absent") == []) + #expect(option.currentValue == "model-b") + } + + @Test + func unknownSessionSelectorsKeepUpstreamLabelsAndChoiceDescriptions() throws { + let option = try #require(AgentSessionConfigOption.parse([[ + "id": "custom-mode", "name": "Custom control", "category": "custom", "type": "select", "currentValue": "custom-choice", + "options": [["value": "custom-choice", "name": "Custom choice", "description": "Upstream detail"]] + ]]).first) + #expect(AgentSessionSelectorPresentation.title(option) == "Custom control") + #expect(AgentSessionSelectorPresentation.currentTitle(option) == "Custom choice") + #expect(option.choices.first?.description == "Upstream detail") + } + + @Test + func menuMarksKeepTheirNativeSizeWithoutMutatingTheHero() throws { + let hero = try #require(AgentBrandIconLoader.image(name: "Codex", size: 60)) + let menu = try #require(AgentBrandIconLoader.image(name: "Codex", size: 16)) + let model = try #require(AgentBrandIconLoader.image(name: "Codex", size: 12)) + #expect(hero.size == NSSize(width: 60, height: 60)) + #expect(menu.size == NSSize(width: 16, height: 16)) + #expect(model.size == NSSize(width: 12, height: 12)) + #expect(hero !== menu) + } + + @Test + func bundledVendorMarksHaveVisiblePixels() throws { + for name in ["Codex", "Claude"] { + let image = try #require(AgentBrandIconLoader.image(name: name)) + #expect(image.isTemplate) + #expect(image.size == NSSize(width: 64, height: 64)) + let data = try #require(image.tiffRepresentation) + let bitmap = try #require(NSBitmapImageRep(data: data)) + var opaquePixels = 0 + for y in 0.. 0.1 { + opaquePixels += 1 + } + } + } + #expect(opaquePixels > 100) + #expect(opaquePixels < bitmap.pixelsWide * bitmap.pixelsHigh) + } + #expect(AgentBrandIconLoader.image(name: "Custom") == nil) + } + + @Test + func inputSplitStaysWithinNarrowAndWidePanels() throws { + let host = NSHostingView(rootView: AgentConversationLayout { + AgentHeroView(agentName: "Codex", agentVersion: "1.13.1", onTap: {}) + } composer: { + Color.clear + }) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 500), + styleMask: [.borderless], backing: .buffered, defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + for size in [NSSize(width: 320, height: 500), NSSize(width: 760, height: 1000), + NSSize(width: 320, height: 300)] { + host.frame.size = size + host.layoutSubtreeIfNeeded() + let handle = try #require(splitHandle(in: host)) + let rect = handle.convert(handle.bounds, to: host) + #expect(rect.minY >= 0 && rect.maxY <= size.height) + #expect(rect.width == size.width) + #expect(rect.height == SplitHandleView.hitThickness) + } + } + + private func splitHandle(in view: NSView) -> SplitHandleInteractionView? { + if let handle = view as? SplitHandleInteractionView { return handle } + return view.subviews.lazy.compactMap { splitHandle(in: $0) }.first + } +} diff --git a/macos/Tests/LitheTests/AgentFileReferenceTests.swift b/macos/Tests/LitheTests/AgentFileReferenceTests.swift new file mode 100644 index 000000000..8abe771e9 --- /dev/null +++ b/macos/Tests/LitheTests/AgentFileReferenceTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import LitheAgentConversationModule + +struct AgentFileReferenceTests { + @Test + func repeatedDropsDeduplicateStandardizedURLsAndPreserveOrder() throws { + let first = URL(fileURLWithPath: "/example/project/中文 File.swift") + let second = URL(fileURLWithPath: "/example/project/image.png") + let files = try AgentFileReference.adding([first, second], to: []) + let repeated = try AgentFileReference.adding([ + URL(fileURLWithPath: "/example/project/sub/../中文 File.swift"), first + ], to: files) + #expect(repeated == files) + #expect(files.map(\.name) == ["中文 File.swift", "image.png"]) + #expect(URL(string: files[0].id)?.path == first.path) + let removed = repeated.filter { $0.id != files[0].id } + #expect(try AgentFileReference.adding([first], to: removed).map(\.name) == ["image.png", "中文 File.swift"]) + } + + @Test + func invalidOrOversizedBatchKeepsExistingDraftIntact() throws { + let urls = (0..= 0 && fieldFrame.maxX <= width) + #expect(fieldFrame.width >= 120) + // Optional visual evidence uses only synthetic sessions, never user conversations. + if let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_HISTORY_SCREENSHOTS"], + let bitmap = host.bitmapImageRepForCachingDisplay(in: host.bounds) { + host.cacheDisplay(in: host.bounds, to: bitmap) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: folder).appendingPathComponent("history-\(name).png")) + } + } + } + } + + private func textField(in view: NSView) -> NSTextField? { + if let field = view as? NSTextField { return field } + return view.subviews.lazy.compactMap { textField(in: $0) }.first + } +} + +@MainActor +private final class HistoryFixture { + let feature: AgentConnectionModel + let transport = HistoryTransport() + let persistence = MacAgentHistoryPersistence(store: HistoryKeyValueStore()) + let exporter = HistoryExporter() + let workspace = URL(fileURLWithPath: "/example/project") + lazy var history = makeHistory() + + init() { feature = AgentConnectionModel(transport: transport) } + + func makeHistory() -> AgentHistoryFeatureModel { + AgentHistoryFeatureModel(connection: feature, workspaceURL: workspace, agentID: "codex", + persistence: persistence, exporter: exporter) + } + + func run(_ operation: (HistoryFixture) async throws -> Void) async throws { + do { + try feature.connect(configuration: AgentLaunchConfiguration( + agentID: "example", command: "example", arguments: [], workspaceURL: workspace, + dataDirectory: URL(fileURLWithPath: "/example/data"), providerProtocol: "responses", + providerEndpoint: "https://example.invalid", apiKey: "test", providerName: "Example", + model: "", allowsInsecureHTTP: false + )) + try emit(["kind": "ready", "canListSessions": true, "canLoadSessions": true]) + try emit(["kind": "sessions", "token": transport.connection.commands.last?["token"] as Any, + "sessions": [["sessionId": "session-a", "title": "Original title", "updatedAt": "2026-09-26T10:00:00Z"], + ["sessionId": "session-b", "title": "Another conversation", "updatedAt": "2026-09-25T10:00:00Z"]]]) + try await operation(self) + } catch { + history.cancelExport() + await feature.stop() + await history.stop() + throw error + } + history.cancelExport() + await feature.stop() + await history.stop() + } + + func emit(_ event: [String: Any]) throws { feature.receive(Self.json(event)) } + static func json(_ event: [String: Any]) -> String { + // All callers supply fixed JSON-compatible fake events. + String(decoding: try! JSONSerialization.data(withJSONObject: event), as: UTF8.self) + } +} + +@MainActor +private final class HistoryTransport: AgentConversationTransport { + let connection = HistoryConnection() + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + connection + } +} + +@MainActor +private final class HistoryConnection: AgentConnection { + var commands: [[String: Any]] = [] + var onCommand: (([String: Any]) -> Void)? + func send(commandJSON: String) throws { + let command = try #require(JSONSerialization.jsonObject(with: Data(commandJSON.utf8)) as? [String: Any]) + commands.append(command) + onCommand?(command) + } + func close() async {} +} + +@MainActor +private final class HistoryExporter: AgentHistoryExporting { + var destination: URL? = URL(fileURLWithPath: "/example/export.md") + var markdown: String? + var fails = false + func chooseDestination() -> URL? { destination } + func writeMarkdown(_ markdown: String, to url: URL) async throws { + if fails { throw CocoaError(.fileWriteNoPermission) } + self.markdown = markdown + } +} + +@MainActor +private final class FailingHistoryPersistence: AgentHistoryPersisting { + var fails = false + private var metadata: [String: AgentHistoryMetadata] = [:] + func load(workspaceURL: URL, agentID: String) throws -> [String: AgentHistoryMetadata] { metadata } + func save(_ metadata: [String: AgentHistoryMetadata], workspaceURL: URL, agentID: String) throws { + if fails { throw CocoaError(.fileWriteNoPermission) } + self.metadata = metadata + } +} + +private final class HistoryKeyValueStore: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift b/macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift new file mode 100644 index 000000000..4ebf3be47 --- /dev/null +++ b/macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift @@ -0,0 +1,257 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +/// Agent panel settings state, driven by a controllable management service. +@MainActor +struct AgentManagementFeatureModelTests { + @Test + func refreshReportsDetectedRuntimeAndCatalog() async throws { + let service = TestAgentManagementService() + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + #expect(feature.phase == .idle, "nothing runs before the page asks") + await feature.refresh() + #expect(feature.phase == .ready) + #expect(feature.status?.agents.map(\.id) == ["codex-acp", "claude-acp"]) + #expect(feature.status?.agents.first?.isInstalled == true) + #expect(feature.status?.agents.last?.issues.first?.contains("Node.js 22") == true) + #expect(await service.statusCalls == 1) + + await service.failStatus("Rust Core is unavailable") + await feature.refresh() + #expect(feature.phase == .failed("Rust Core is unavailable")) + } + + @Test + func installRunsOneOperationAndRefreshesAfterward() async throws { + let service = TestAgentManagementService() + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + await service.holdInstall() + defer { Task { await service.releaseInstall() } } + feature.install("claude-acp") + #expect(feature.busyAgentID == "claude-acp") + feature.uninstall("codex-acp") + try await service.waitUntilInstallStarted() + #expect(await service.uninstallCalls == 0, "a second operation waits for the first") + + await service.failInstall("npm could not install the adapter") + await service.releaseInstall() + try await waitUntilIdle(feature) + #expect(feature.errors["claude-acp"] == "npm could not install the adapter") + #expect(feature.phase == .ready, "the page refreshes after an operation") + } + + @Test + func progressIsVisibleDuringInstallAndClearedAfterFailure() async throws { + let service = TestAgentManagementService() + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + await service.holdInstall() + defer { Task { await service.releaseInstall() } } + feature.install("codex-acp") + try await service.waitUntilInstallStarted() + let progress = AgentInstallProgress(stage: .downloading, downloadedBytes: 1_000_000, + bytesPerSecond: 50_000, elapsedMilliseconds: 20_000, idleMilliseconds: 0) + await service.emitProgress(progress) + let reported = await awaitChange(on: feature) { feature.installProgress == progress } + #expect(reported, "live counters must appear before npm completes") + #expect(feature.busyAgentID == "codex-acp") + await service.failInstall("Download failed") + await service.releaseInstall() + try await waitUntilIdle(feature) + #expect(feature.installProgress == nil) + #expect(feature.operationStartedAt == nil) + #expect(feature.errors["codex-acp"] == "Download failed") + } + + @Test + func cliUpgradePublishesProgressAndClearsItAfterSuccess() async throws { + let service = TestAgentManagementService() + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + await service.holdInstall() + defer { Task { await service.releaseInstall() } } + feature.installCli("codex-acp") + try await service.waitUntilInstallStarted() + let progress = AgentInstallProgress(stage: .installing, downloadedBytes: 1_000_000, + bytesPerSecond: 0, elapsedMilliseconds: 20_000, idleMilliseconds: 1000) + await service.emitProgress(progress) + let reported = await awaitChange(on: feature) { feature.installProgress == progress } + #expect(reported, "CLI upgrades use the same live progress path as adapter installs") + await service.releaseInstall() + try await waitUntilIdle(feature) + #expect(feature.installProgress == nil) + #expect(feature.operationStartedAt == nil) + #expect(feature.errors.isEmpty) + #expect(feature.cliUpdates["codex-acp"] == AgentCliUpdateResult(cliVersion: "0.156.1")) + } + + @Test + func recoveredCliUpgradeKeepsWarningSeparateFromErrorsAndClearsItOnRetry() async throws { + let service = TestAgentManagementService() + let result = AgentCliUpdateResult(cliVersion: "0.157.1", updaterWarning: "Download failed, retry succeeded") + await service.setCliResult(result) + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + feature.installCli("codex-acp") + try await waitUntilIdle(feature) + #expect(feature.errors.isEmpty) + #expect(feature.cliUpdates["codex-acp"] == result) + await feature.refresh() + #expect(feature.cliUpdates["codex-acp"] == result, "refresh preserves the completed operation's warning") + await service.failInstall("Update did not change the CLI") + feature.installCli("codex-acp") + #expect(feature.cliUpdates["codex-acp"] == nil, "a new attempt clears the previous result") + try await waitUntilIdle(feature) + #expect(feature.errors["codex-acp"] == "Update did not change the CLI") + #expect(feature.cliUpdates["codex-acp"] == nil) + } + + @Test + func cliUpdateContractDecodesBothCleanAndRecoveredOutcomes() throws { + struct Responses: Decodable { + let installCli: AgentCliUpdateResult + let installCliRecovered: AgentCliUpdateResult + } + struct Fixture: Decodable { let responses: Responses } + let root = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/agent-management-v1.json")) + let results = try JSONDecoder().decode(Fixture.self, from: data).responses + #expect(results.installCli == AgentCliUpdateResult(cliVersion: "0.156.1")) + #expect(results.installCliRecovered == AgentCliUpdateResult(cliVersion: "0.157.1", updaterWarning: "Download failed; retry completed")) + } + + @Test + func cancelStopsProgressAndClearsTheBusyState() async throws { + let service = TestAgentManagementService() + let feature = AgentManagementFeatureModel(service: service, dataDirectory: URL(fileURLWithPath: "/tmp/lithe-agents")) + await service.holdInstall() + defer { Task { await service.releaseInstall() } } + feature.install("codex-acp") + try await service.waitUntilInstallStarted() + feature.cancelOperation() + #expect(feature.isCancelling) + await service.emitProgress(.init(stage: .downloading, downloadedBytes: 99, + bytesPerSecond: 99, elapsedMilliseconds: 99, idleMilliseconds: 0)) + await service.releaseInstall() + try await waitUntilIdle(feature) + #expect(feature.installProgress == nil) + #expect(!feature.isCancelling) + #expect(feature.errors.isEmpty, "cancellation must not become an install error") + } + + private func waitUntilIdle(_ feature: AgentManagementFeatureModel) async throws { + // The operation task publishes through the feature model, so wait on + // its publications with a local deadline instead of polling. + let idle = await awaitChange(on: feature) { + feature.busyAgentID == nil && feature.phase != .checking + } + #expect(idle, "operation finished within the deadline") + } +} + +private actor TestAgentManagementService: AgentManagementService { + private(set) var statusCalls = 0 + private(set) var uninstallCalls = 0 + private var statusFailure: String? + private var installFailure: String? + private var cliResult = AgentCliUpdateResult(cliVersion: "0.156.1") + func setCliResult(_ result: AgentCliUpdateResult) { cliResult = result } + private var installGate: CheckedContinuation? + private var holdsInstall = false + private var installStarted = false + private var onProgress: (@Sendable (AgentInstallProgress) -> Void)? + func emitProgress(_ progress: AgentInstallProgress) { onProgress?(progress) } + func install(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> String { + self.onProgress = onProgress + return try await install(agentID: agentID, dataDirectory: dataDirectory) + } + /// Test waiting for `install` to be entered; resumed once, by the install + /// or by the deadline. + private var installStartedWaiter: CheckedContinuation? + private var installStartedDeadline: DispatchWorkItem? + + struct Failure: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + func failStatus(_ message: String) { statusFailure = message } + func failInstall(_ message: String) { installFailure = message } + func holdInstall() { holdsInstall = true } + + func releaseInstall() { + holdsInstall = false + installGate?.resume() + installGate = nil + } + + func waitUntilInstallStarted() async throws { + if !installStarted { + let started = await withCheckedContinuation { continuation in + installStartedWaiter = continuation + // Deadline arm: the install task resumes the waiter first in + // the normal case; the timer only fails a stuck test. + let deadline = DispatchWorkItem { + Task { await self.resumeInstallStartedWaiter(false) } + } + installStartedDeadline = deadline + DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(5), execute: deadline) + } + #expect(started, "install started within the deadline") + } + } + + private func resumeInstallStartedWaiter(_ value: Bool) { + guard let waiter = installStartedWaiter else { return } + installStartedWaiter = nil + installStartedDeadline?.cancel() + installStartedDeadline = nil + waiter.resume(returning: value) + } + + func status(dataDirectory: URL) async throws -> AgentManagementStatus { + statusCalls += 1 + if let statusFailure { throw Failure(message: statusFailure) } + return AgentManagementStatus( + environment: AgentRuntimeEnvironment( + node: AgentRuntimeTool(version: "20.11.0", path: "/opt/example/bin/node"), + npm: AgentRuntimeTool(version: "10.2.4", path: "/opt/example/bin/npm"), + usedLoginShell: true + ), + agents: [ + AgentCatalogStatus( + id: "codex-acp", name: "Codex", description: "", package: "@agentclientprotocol/codex-acp", + version: "1.13.1", installedVersion: "1.13.1", protocol: "responses", + minimumNodeMajor: 20, verified: true, issues: [] + ), + AgentCatalogStatus( + id: "claude-acp", name: "Claude", description: "", package: "@agentclientprotocol/claude-agent-acp", + version: "0.81.2", installedVersion: nil, protocol: "anthropicMessages", + minimumNodeMajor: 22, verified: false, issues: ["Claude needs Node.js 22 or later; found 20.11.0."] + ) + ] + ) + } + + func install(agentID: String, dataDirectory: URL) async throws -> String { + installStarted = true + resumeInstallStartedWaiter(true) + if holdsInstall { + await withCheckedContinuation { installGate = $0 } + } + if let installFailure { throw Failure(message: installFailure) } + return "1.0.0" + } + + func uninstall(agentID: String, dataDirectory: URL) async throws { + uninstallCalls += 1 + } + + func installCli(agentID: String, dataDirectory: URL) async throws -> AgentCliUpdateResult { cliResult } + func installCli(agentID: String, dataDirectory: URL, + onProgress: @escaping @Sendable (AgentInstallProgress) -> Void) async throws -> AgentCliUpdateResult { + _ = try await install(agentID: agentID, dataDirectory: dataDirectory, onProgress: onProgress) + return cliResult + } +} diff --git a/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift b/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift new file mode 100644 index 000000000..218471d0a --- /dev/null +++ b/macos/Tests/LitheTests/AgentProviderConfigurationTests.swift @@ -0,0 +1,255 @@ +import AppKit +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +@MainActor +@Suite("Agent provider configuration") +struct AgentProviderConfigurationTests { + @Test func configurationInputKeepsLiteralSyntax() { + let editor = MacConfigurationTextView() + #expect(!editor.isAutomaticQuoteSubstitutionEnabled) + #expect(!editor.isAutomaticDashSubstitutionEnabled) + #expect(!editor.isAutomaticTextReplacementEnabled) + #expect(!editor.isAutomaticSpellingCorrectionEnabled) + let toml = "model = \"fixture-model\"\n" + editor.insertText(toml, replacementRange: NSRange(location: 0, length: 0)) + #expect(editor.string == toml) + let json = #"{"OPENAI_API_KEY":"fixture-secret"}"# + editor.insertText(json, replacementRange: NSRange(location: 0, length: editor.string.utf16.count)) + #expect(editor.string == json) + } + @Test(.enabled(if: ProcessInfo.processInfo.environment["LITHE_RUN_AGENT_PROVIDER_INTEGRATION"] == "1")) + func nativeParserUsesSharedFixtureAndRoundTripsEditorTemplates() throws { + let fixtureURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appendingPathComponent("shared/fixtures/agent/provider-configuration-v1.json") + let fixture = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: fixtureURL)) as? [String: Any]) + let cases = try #require(fixture["cases"] as? [[String: Any]]) + let parser = MacAgentProviderConfigurationParser(core: RustCoreBridge()) + #expect(throws: AgentProviderConfigurationError.invalidConfigurationQuotes) { + try parser.parse(source: .codex, configuration: "model = “fixture-model”", + authentication: #"{"OPENAI_API_KEY":"fixture-secret"}"#) + } + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: parser) + for value in cases { + let sourceName = try #require(value["source"] as? String) + let source = try #require(AIConfigurationSourceKind(rawValue: sourceName)) + let configuration = try #require(value["configuration"] as? String) + let expected = try #require(value["expected"] as? [String: String]) + var draft = AgentProviderDraft(source: source, name: "Fixture", configuration: configuration, + authentication: #"{"OPENAI_API_KEY":"fixture-secret"}"#) + let (provider, key) = try feature.validate(draft) + #expect(provider.endpoint == expected["endpoint"]) + #expect(provider.model == expected["model"]) + #expect(provider.apiProtocol.rawValue == expected["apiProtocol"]) + #expect(key == "fixture-secret") + try feature.save(provider, key: key, replacing: nil) + // Editing reconstructs only the fields we promise to retain, including TOML string escaping. + draft = try feature.draft(source: source, provider: provider) + let (roundTrip, _) = try feature.validate(draft) + #expect(roundTrip == provider) + if source == .codex { + draft.authentication = #"{"OPENAI_API_KEY":123}"# + #expect(throws: AgentProviderConfigurationError.self) { try feature.validate(draft) } + } + } + #expect(throws: AgentProviderConfigurationError.self) { + try parser.parse(source: .claude, configuration: #"{"env":{"ANTHROPIC_AUTH_TOKEN":"fixture-token"}}"#, authentication: "") + } + } + @Test func unreadableCredentialStorePreservesItsContents() throws { + let url = FileManager.default.temporaryDirectory.appendingPathComponent("provider-store-\(UUID().uuidString).json") + defer { try? FileManager.default.removeItem(at: url) } + for text in ["malformed fixture", #"{"version":99,"values":{"fixture":"opaque"}}"#] { + let original = Data(text.utf8) + try original.write(to: url) + let store = MacLocalSecretStore(fileURL: url) + #expect(throws: (any Error).self) { try store.write("fixture-secret", key: "provider") } + #expect(throws: (any Error).self) { try store.delete(key: "provider") } + #expect(try Data(contentsOf: url) == original) + } + } + @Test func savesKeySeparatelyAndPreservesCommitProvider() throws { + let store = ProviderSettingsStore() + let settings = AppSettings(store: store) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser()) + settings.commitMessageAI.activeProviderID = nil + var draft = try feature.draft(source: .codex) + draft.name = " My provider " + let (provider, key) = try feature.validate(draft) + try feature.save(provider, key: key, replacing: nil) + #expect(settings.commitMessageAI.activeProviderID == nil) + #expect(settings.commitMessageAI.providers.last?.name == "My provider") + #expect(keys.read(key: provider.apiKeyIdentifier) == "fixture-secret") + let persisted = String(decoding: try JSONEncoder().encode(settings.commitMessageAI), as: UTF8.self) + #expect(!persisted.contains("fixture-secret")) + let restored = AppSettings(store: store) + #expect(restored.commitMessageAI.providers.contains { $0.id == provider.id }) + #expect(restored.commitMessageAI.activeProviderID == nil) + } + + @Test func draftCancellationHasNoPersistenceEffects() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser()) + let before = settings.commitMessageAI + let draft = try feature.draft(source: .claude) + #expect(draft.providerID == nil) + #expect(settings.commitMessageAI == before) + #expect(keys.values.isEmpty) + } + + @Test func failedStorageDoesNotPublishOrDeleteProfile() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser()) + var draft = try feature.draft(source: .codex) + draft.name = "Fixture" + let (provider, key) = try feature.validate(draft) + let before = settings.commitMessageAI + keys.fail = true + #expect(throws: ProviderStorageError.self) { try feature.save(provider, key: key, replacing: nil) } + #expect(settings.commitMessageAI == before) + keys.fail = false + try feature.save(provider, key: key, replacing: nil) + settings.setAgentProvider(provider.id, for: "codex-acp", name: "Codex") + keys.fail = true + #expect(throws: ProviderStorageError.self) { try feature.remove(provider) } + #expect(settings.agentProvider(for: "codex-acp")?.id == provider.id) + keys.fail = false + try feature.remove(provider) + #expect(settings.agentProvider(for: "codex-acp") == nil) + #expect(keys.read(key: provider.apiKeyIdentifier) == nil) + } + + @Test func editingKeepsProviderAndCredentialIdentity() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser()) + var draft = try feature.draft(source: .codex) + draft.name = "First" + let (first, key) = try feature.validate(draft) + try feature.save(first, key: key, replacing: nil) + let count = settings.commitMessageAI.providers.count + var edit = try feature.draft(source: .codex, provider: first) + edit.name = "Renamed" + let (updated, newKey) = try feature.validate(edit) + try feature.save(updated, key: newKey, replacing: first) + #expect(updated.id == first.id) + #expect(updated.apiKeyIdentifier == first.apiKeyIdentifier) + #expect(settings.commitMessageAI.providers.count == count) + #expect(settings.commitMessageAI.providers.last?.name == "Renamed") + try feature.remove(updated) + #expect(throws: AgentProviderConfigurationError.self) { try feature.validate(edit) } + } + + @Test(arguments: [false, true]) + func suspendedSaveRejectsDeletedOrChangedProvider(otherWindowEdits: Bool) throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser()) + var draft = try feature.draft(source: .codex) + draft.name = "Original" + let (original, key) = try feature.validate(draft) + try feature.save(original, key: key, replacing: nil) + settings.setAgentProvider(original.id, for: "codex-acp", name: "Codex") + + var edit = try feature.draft(source: .codex, provider: original) + edit.name = "Late edit" + let (pending, pendingKey) = try feature.validate(edit) + // The first window has validated and is awaiting connection closure. Apply the + // second window's mutation before resuming its save, without scheduler timing. + let otherWindow = AgentProviderConfiguration(settings: settings, secureStore: keys, + parser: ProviderParser(key: "replacement-secret")) + if otherWindowEdits { + var replacement = try otherWindow.draft(source: .codex, provider: original) + replacement.name = "Other window" + let (provider, replacementKey) = try otherWindow.validate(replacement) + try otherWindow.save(provider, key: replacementKey, replacing: original) + } else { + try otherWindow.remove(original) + } + let expectedProfiles = settings.commitMessageAI + let expectedKeys = keys.values + let expectedSelection = settings.agentProvider(for: "codex-acp") + + #expect(throws: AgentProviderConfigurationError.invalidConfiguration) { + try feature.save(pending, key: pendingKey, replacing: original) + } + #expect(settings.commitMessageAI == expectedProfiles) + #expect(keys.values == expectedKeys, "A stale save must not restore or overwrite credentials") + #expect(settings.agentProvider(for: "codex-acp") == expectedSelection) + } + + @Test func rejectsProtocolMismatchAndCredentialBearingURLs() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let protocols: [CommitMessageAPIProtocol] = [.chatCompletions, .anthropicMessages] + for apiProtocol in protocols { + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser(apiProtocol: apiProtocol)) + var draft = try feature.draft(source: .codex) + draft.name = "Fixture" + #expect(throws: AgentProviderConfigurationError.self) { try feature.validate(draft) } + } + for endpoint in ["https://user:password@example.test", "https://example.test?key=secret", "file:///example", "https://example.test#fragment"] { + let feature = AgentProviderConfiguration(settings: settings, secureStore: keys, parser: ProviderParser(endpoint: endpoint)) + var draft = try feature.draft(source: .codex) + draft.name = "Fixture" + #expect(throws: AgentProviderConfigurationError.self) { try feature.validate(draft) } + } + #expect(keys.values.isEmpty) + } + + @Test func requiresExplicitHTTPConsentAndNonemptyKey() throws { + let settings = AppSettings(store: ProviderSettingsStore()) + let keys = ProviderSecureStore() + let http = AgentProviderConfiguration(settings: settings, secureStore: keys, + parser: ProviderParser(endpoint: "http://example.test/v1")) + var draft = try http.draft(source: .codex) + draft.name = "Fixture" + #expect(throws: AgentProviderConfigurationError.self) { try http.validate(draft) } + draft.allowsInsecureHTTP = true + #expect(try http.validate(draft).0.allowsInsecureHTTP) + let missingKey = AgentProviderConfiguration(settings: settings, secureStore: keys, + parser: ProviderParser(key: " \n")) + #expect(throws: AgentProviderConfigurationError.self) { try missingKey.validate(draft) } + #expect(keys.values.isEmpty) + } +} + +private struct ProviderParser: AgentProviderConfigurationParsing { + var endpoint = "https://example.test/v1" + var apiProtocol: CommitMessageAPIProtocol = .responses + var key: String? = "fixture-secret" + func parse(source: AIConfigurationSourceKind, configuration: String, authentication: String) throws -> AIConfigurationSnapshot { + AIConfigurationSnapshot(source: source, providerName: "fixture", endpoint: endpoint, model: "fixture-model", + apiProtocol: apiProtocol, reasoningEffort: nil, requiresAPIKey: true, apiKey: key) + } +} +private enum ProviderStorageError: Error { case failed } +private final class ProviderSecureStore: SecureStore, @unchecked Sendable { + var values: [String: String] = [:] + var fail = false + func read(key: String) -> String? { values[key] } + func write(_ value: String, key: String) throws { + if fail { throw ProviderStorageError.failed } + values[key] = value + } + func delete(key: String) throws { + if fail { throw ProviderStorageError.failed } + values[key] = nil + } +} +private final class ProviderSettingsStore: KeyValueStore { + private var values: [String: Any] = [:] + func data(forKey key: String) -> Data? { values[key] as? Data } + func object(forKey key: String) -> Any? { values[key] } + func string(forKey key: String) -> String? { values[key] as? String } + func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } + func set(_ value: Any?, forKey key: String) { values[key] = value } +} diff --git a/macos/Tests/LitheTests/AgentSetupDiagnosticsTests.swift b/macos/Tests/LitheTests/AgentSetupDiagnosticsTests.swift new file mode 100644 index 000000000..db8e4c524 --- /dev/null +++ b/macos/Tests/LitheTests/AgentSetupDiagnosticsTests.swift @@ -0,0 +1,116 @@ +import Foundation +import LitheCoreContracts +import Testing +@testable import Lithe + +struct AgentSetupDiagnosticsTests { + @Test + func cliOwnershipControlsAutomaticUpdateAndProvidesManualGuidance() throws { + let root = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + let data = try Data(contentsOf: root.appendingPathComponent("shared/fixtures/agent/agent-management-v1.json")) + let fixture = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + let installations = try #require(fixture["cliInstallations"] as? [String: Any]) + let environment = AgentRuntimeEnvironment(node: nil, npm: nil, usedLoginShell: true) + for name in ["homebrew", "npm", "native", "unknown", "npmEnvironmentMismatch"] { + let installation = try JSONDecoder().decode(AgentCliInstallation.self, + from: JSONSerialization.data(withJSONObject: try #require(installations[name]))) + let agent = AgentCatalogStatus(id: "codex-acp", name: "Codex", description: "", package: "adapter", + version: "1", installedVersion: "1", protocol: "responses", minimumNodeMajor: 20, verified: true, + cli: .init(name: "Codex CLI", command: "codex", minimumVersion: "0.156.0", installHint: "npm install", + detected: .init(version: "0.142.5", path: "/example/bin/codex"), installation: installation), issues: []) + let check = try #require(AgentSetupDiagnostics.preflight(for: agent, environment: environment, hasProvider: true).first { $0.id == "cli" }) + #expect(check.fix == (installation.canUpdate ? .updateCli : nil)) + #expect(check.message.contains(installation.source == .homebrew ? "Homebrew" : + NSLocalizedString(installation.updateHint, comment: ""))) + if installation.source == .homebrew { + #expect(check.message.contains("brew upgrade --cask codex")) + #expect(!check.message.contains("npm install")) + } + } + } + + @Test + func versionComparisonIgnoresPrefixesAndPreReleaseSuffixes() { + #expect(AgentSetupDiagnostics.isVersion("0.156.1", atLeast: "0.156.0")) + #expect(!AgentSetupDiagnostics.isVersion("0.144.1", atLeast: "0.156.0")) + #expect(AgentSetupDiagnostics.isVersion("v2.1.280-beta", atLeast: "2.1.280")) + #expect(AgentSetupDiagnostics.isVersion("2.2", atLeast: "2.1.280")) + #expect(AgentSetupDiagnostics.majorVersion(of: "v24.14.0") == 24) + } + + @Test + func issuesMirrorTheRuntimeAndCliChecksTheHostPerforms() { + let node = AgentRuntimeTool(version: "20.11.0", path: "/opt/node/bin/node") + let agent = AgentCatalogStatus( + id: "claude-acp", name: "Claude", description: "", package: "pkg", version: "1", + installedVersion: nil, protocol: "anthropicMessages", minimumNodeMajor: 22, verified: false, + cli: AgentCliStatus( + name: "Claude Code", command: "claude", minimumVersion: "2.1.280", installHint: "npm i", + detected: AgentRuntimeTool(version: "2.1.260", path: "/usr/local/bin/claude") + ), + issues: ["x", "y"] + ) + let issues = AgentSetupDiagnostics.issues( + for: agent, + environment: AgentRuntimeEnvironment(node: node, npm: nil, usedLoginShell: true) + ) + #expect(issues == [ + .nodeTooOld(minimumMajor: 22, found: "20.11.0"), + .npmMissing, + .cliTooOld(name: "Claude Code", minimumVersion: "2.1.280", found: "2.1.260") + ]) + #expect(issues.allSatisfy { !$0.message.isEmpty }) + + let healthy = AgentSetupDiagnostics.issues( + for: agent, + environment: AgentRuntimeEnvironment( + node: AgentRuntimeTool(version: "22.0.0", path: ""), + npm: AgentRuntimeTool(version: "10", path: ""), + usedLoginShell: true + ) + ) + #expect(healthy == [.cliTooOld(name: "Claude Code", minimumVersion: "2.1.280", found: "2.1.260")]) + } +} + +extension AgentSetupDiagnosticsTests { + @Test + func preflightListsRuntimeCliAdapterAndProviderInAFixedOrder() { + let agent = AgentCatalogStatus( + id: "codex-acp", name: "Codex", description: "", package: "@agentclientprotocol/codex-acp", + version: "1.13.1", installedVersion: "1.12.0", protocol: "responses", minimumNodeMajor: 20, verified: true, + cli: AgentCliStatus(name: "Codex CLI", command: "codex", minimumVersion: "0.156.0", + installHint: "npm install -g @openai/codex", detected: nil), + issues: [] + ) + let environment = AgentRuntimeEnvironment( + node: AgentRuntimeTool(version: "24.14.0", path: "/opt/node/bin/node"), + npm: AgentRuntimeTool(version: "11.9.0", path: "/opt/node/bin/npm"), + usedLoginShell: true + ) + let checks = AgentSetupDiagnostics.preflight(for: agent, environment: environment, hasProvider: false) + #expect(checks.map(\.id) == ["node", "npm", "cli", "adapter", "provider"]) + #expect(checks.map(\.status) == [.pass, .pass, .fail, .warn, .fail]) + #expect(checks[2].fix == .installCli) + #expect(checks[3].fix == .update) + #expect(checks[4].fix == .fetchLocalConfiguration) + #expect(AgentSetupDiagnostics.summary(of: checks) == .fail) + + // An install is offered only when Node.js and npm are usable; an + // outdated CLI does not block installing the adapter. + let blocked = AgentCatalogStatus( + id: "claude-acp", name: "Claude", description: "", package: "pkg", version: "0.81.2", + installedVersion: nil, protocol: "anthropicMessages", minimumNodeMajor: 22, verified: false, + cli: nil, issues: ["Node too old"] + ) + let oldNode = AgentRuntimeEnvironment(node: AgentRuntimeTool(version: "20.0.0", path: ""), npm: environment.npm, usedLoginShell: true) + let blockedChecks = AgentSetupDiagnostics.preflight(for: blocked, environment: oldNode, hasProvider: true) + #expect(blockedChecks.map(\.id) == ["node", "npm", "adapter", "provider"]) + #expect(blockedChecks.first { $0.id == "adapter" }?.fix == nil) + #expect(!AgentSetupDiagnostics.canInstallAdapter(blockedChecks)) + #expect(AgentSetupDiagnostics.canInstallAdapter(checks)) + #expect(AgentSetupDiagnostics.summary(of: []) == nil) + } +} diff --git a/macos/Tests/LitheTests/CommitMessageTests.swift b/macos/Tests/LitheTests/CommitMessageTests.swift index e7644d00d..9740906b6 100644 --- a/macos/Tests/LitheTests/CommitMessageTests.swift +++ b/macos/Tests/LitheTests/CommitMessageTests.swift @@ -7,6 +7,23 @@ import Testing @Suite("Commit message generation") struct CommitMessageTests { + @Test + func codexDefaultModelDoesNotRequireAProviderOrCredentials() { + let config = """ + model = "current-model" # local default + [profiles.legacy] + model = "obsolete-model" + [agents.worker] + model = "worker-model" + """ + #expect(MacCodexConfigurationParser.parseModel(config: config) == "current-model") + #expect(MacCodexConfigurationParser.parse(config: config, authData: nil) == nil, + "reading a model must not grant API access or invent a provider") + #expect(MacCodexConfigurationParser.parseModel(config: "model = \"\"") == "") + #expect(MacCodexConfigurationParser.parseModel(config: "[profiles.legacy]\nmodel = \"obsolete-model\"") == "", + "a missing top-level model delegates to the CLI instead of a nested profile or stale import") + } + @Test func parsesCodexResponsesConfigurationAndAuthKey() throws { let config = """ @@ -374,6 +391,58 @@ private let testCommitMessageInput = CommitMessageInput( @Suite("Commit message settings") @MainActor struct CommitMessageSettingsTests { + @Test + func linkedAgentModelsRefreshWithoutChangingOtherProviderSettings() throws { + let store = InMemoryKeyValueStore() + let settings = AppSettings(store: store) + let codex = AIProviderProfile(name: "Codex", endpoint: "https://old.example.test/v1", model: "obsolete-model", + apiProtocol: .responses, apiKeyIdentifier: "codex-key", credentialSource: .codex) + let claude = AIProviderProfile(name: "Claude", endpoint: "https://claude.example.test", model: "old-claude", + apiProtocol: .anthropicMessages, apiKeyIdentifier: "claude-key", credentialSource: .claude) + let manual = AIProviderProfile(name: "Manual", endpoint: "https://manual.example.test/v1", model: "manual-model", + apiProtocol: .responses, apiKeyIdentifier: "manual-key", credentialSource: .local) + let unlinked = AIProviderProfile(name: "Unlinked", endpoint: codex.endpoint, model: codex.model, + apiProtocol: .responses, apiKeyIdentifier: "unlinked-key", credentialSource: .codex) + var initial = settings.commitMessageAI + initial.providers = [codex, claude, manual, unlinked] + initial.activeProviderID = manual.id + settings.commitMessageAI = initial + settings.setAgentProvider(codex.id, for: "codex-acp", name: "Codex") + settings.setAgentProvider(claude.id, for: "claude-acp", name: "Claude") + settings.setAgentProvider(manual.id, for: "custom", name: "Manual") + let snapshots: [AIConfigurationSnapshot] = [ + .init(source: .codex, providerName: "Current", endpoint: "https://new.example.test/v1", model: "current-codex", + apiProtocol: .responses, reasoningEffort: nil, requiresAPIKey: true, apiKey: "test-secret"), + .init(source: .claude, providerName: "Current", endpoint: "https://new.example.test", model: "current-claude", + apiProtocol: .anthropicMessages, reasoningEffort: nil, requiresAPIKey: true, apiKey: "test-secret") + ] + settings.refreshAgentModels(from: snapshots.map { .init(source: $0.source, model: $0.model) }) + var expected = initial + expected.providers[0].model = "current-codex" + expected.providers[1].model = "current-claude" + #expect(settings.commitMessageAI == expected) + #expect(settings.agentProvider(for: "codex-acp")?.model == "current-codex") + #expect(AppSettings(store: store).commitMessageAI == expected, "the next connection must not restore the stale import") + let saved = try #require(store.data(forKey: "settings.commitMessageAI")) + #expect(!String(decoding: saved, as: UTF8.self).contains("test-secret")) + } + + @Test + func agentModelRefreshKeepsMissingSourcesAndSupportsAnEmptyCliDefault() { + let settings = AppSettings(store: InMemoryKeyValueStore()) + let provider = AIProviderProfile(name: "Codex", endpoint: "https://example.test/v1", model: "saved-model", + apiProtocol: .responses, apiKeyIdentifier: "codex-key", credentialSource: .codex) + var initial = settings.commitMessageAI + initial.providers = [provider] + initial.activeProviderID = provider.id + settings.commitMessageAI = initial + settings.setAgentProvider(provider.id, for: "codex-acp", name: "Codex") + settings.refreshAgentModels(from: []) + #expect(settings.commitMessageAI == initial, "missing configuration must not invent a model") + settings.refreshAgentModels(from: [.init(source: .codex, model: "")]) + #expect(settings.agentProvider(for: "codex-acp")?.model == "", "an empty local model delegates to the agent") + } + @Test func legacyAISettingsGainPullRequestDefaults() throws { var object = try #require( diff --git a/macos/Tests/LitheTests/EditorTabLayoutTests.swift b/macos/Tests/LitheTests/EditorTabLayoutTests.swift index 23b56c31b..b6407679c 100644 --- a/macos/Tests/LitheTests/EditorTabLayoutTests.swift +++ b/macos/Tests/LitheTests/EditorTabLayoutTests.swift @@ -12,7 +12,7 @@ struct EditorTabLayoutTests { let url = URL(fileURLWithPath: "/workspace/UploadService.java") #expect( - EditorDocumentIconResolver.kind(for: url, resolvedJavaKind: .javaInterface) + EditorDocumentIconResolver.kind(for: url, resolvedKind: .javaInterface) == .javaInterface ) } @@ -22,11 +22,19 @@ struct EditorTabLayoutTests { let url = URL(fileURLWithPath: "/workspace/UploadService.java") #expect( - EditorDocumentIconResolver.kind(for: url, resolvedJavaKind: nil) + EditorDocumentIconResolver.kind(for: url, resolvedKind: nil) == .javaGeneric ) } + @Test + func extensionlessTextTabUsesTheResolvedProjectTreeIcon() { + let url = URL(fileURLWithPath: "/in-memory/.swift-version") + + #expect(EditorDocumentIconResolver.kind(for: url, resolvedKind: nil) == .generic) + #expect(EditorDocumentIconResolver.kind(for: url, resolvedKind: .plainText) == .plainText) + } + @Test func flowLayoutWrapsByIntrinsicWidth() { let rows = EditorTabFlowPlanner.rows( diff --git a/macos/Tests/LitheTests/LitheCoreLogicTests.swift b/macos/Tests/LitheTests/LitheCoreLogicTests.swift index b5ead0bfa..d6d3f0332 100644 --- a/macos/Tests/LitheTests/LitheCoreLogicTests.swift +++ b/macos/Tests/LitheTests/LitheCoreLogicTests.swift @@ -5634,6 +5634,42 @@ struct EditorDocumentTests { #expect(operations.readACount == (readFails || discarded ? 2 : 1)) } + @Test + @MainActor + func externalBinaryReplacementInvalidatesOpenTabIcon() async throws { + let workspace = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: workspace) } + let file = workspace.appendingPathComponent(".swift-version") + try Data("6.3.3\n".utf8).write(to: file) + let storage = MacFileStorage() + let model = DocumentFeatureModel( + operations: EmptyWorkspaceOperations(readFileValue: "6.3.3\n"), + documentLifecycleDecider: PreviewExternalChangeLifecycleDecider(), + fileOperations: MacWorkspaceFileOperations(), fileStorage: storage, + binaryFileViewerRegistry: BinaryFileViewerRegistry() + ) + defer { model.reset() } + model.configure( + workspaceURLProvider: { workspace }, autoSaveEnabledProvider: { false }, autoSaveDelayProvider: { 0 }, + notify: { _ in }, onDocumentOpened: { _ in }, onDocumentChanged: { _ in }, onDocumentClosed: { _ in }, + onRecordSave: { _, _ in }, onRecordDiscard: { _ in }, onRecordExternalChanges: { _ in }, + onDocumentCollectionChanged: {}, onProjectCloseReady: {} + ) + await model.openFileAsync(file, isReadOnly: false, displayPath: nil, activateWhenReady: true) + let document = try #require(model.activeDocument) + let initialRevision = document.iconContentRevision + let initialIcon = await WorkspaceFileIconResolver.resolve(for: file, suggested: .generic, storage: storage) + #expect(initialIcon.kind == .plainText) + + try Data([0, 0xFF, 0]).write(to: file) + await model.reconcileExternalChanges([file]) + + let currentIcon = await WorkspaceFileIconResolver.resolve(for: file, suggested: .generic, storage: storage) + #expect(document.iconContentRevision > initialRevision) + #expect(currentIcon.kind == .binary) + } + @Test(arguments: ["watcher", "remoteWatcher", "reopen", "promotion", "save"]) @MainActor func previewExternalChangesNeverSilentlyOverwriteDisk(trigger: String) async throws { diff --git a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift index c15840e5a..4bd1056ff 100644 --- a/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift +++ b/macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -79,6 +79,7 @@ struct SettingsAppearanceContainerTests { SettingsWindowChrome.configure(window, title: "Settings", themePreference: theme) host.layoutSubtreeIfNeeded() + #expect(window.level == .normal, "Settings must use normal window ordering after appearance changes and reopening") #expect(window.appearance?.name == theme.windowAppearance?.name) #expect(window.styleMask.contains(.fullSizeContentView)) #expect(window.titlebarAppearsTransparent) diff --git a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift index 011da553e..995f51434 100644 --- a/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift +++ b/macos/Tests/LitheTests/WorkbenchFeatureModelTests.swift @@ -71,6 +71,34 @@ struct WorkbenchFeatureModelTests { #expect(model.activeToolWindow == nil) } + @Test + func agentDocksBesideTheEditorAndSharesTheRightSlotWithMaven() { + // The Agent conversation must keep the editor visible, so it takes the + // right dock instead of the bottom tool window. Maven and Agent share + // that dock, and neither replaces a bottom tool window. + let model = WorkbenchFeatureModel() + model.setVisibility(.terminal, isVisible: true) + model.setVisibility(.agent, isVisible: true) + #expect(model.isVisible(.agent)) + #expect(model.activeToolWindow == .terminal) + #expect(model.activeRightToolWindow == .agent) + + model.setVisibility(.maven, isVisible: true) + #expect(model.isVisible(.maven)) + #expect(!model.isVisible(.agent)) + #expect(model.isVisible(.terminal)) + + model.toggleVisibility(.agent) + #expect(model.isVisible(.agent)) + #expect(!model.isVisible(.maven)) + + model.hideBottomToolWindow() + #expect(model.isVisible(.agent)) + + model.reset() + #expect(model.activeRightToolWindow == nil) + } + @Test func workspaceResetClearsBothMavenAreas() { let model = WorkbenchFeatureModel() diff --git a/macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift b/macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift index 4c1ec0fa4..524eb637d 100644 --- a/macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift +++ b/macos/Tests/LitheTests/WorkbenchHoverTooltipTests.swift @@ -77,6 +77,21 @@ struct WorkbenchHoverTooltipTests { #expect(right.maxX <= rightSource.minX - 4) } + @Test + func contextUsageTooltipOpensAboveItsSourceInsideANarrowPanel() async throws { + let source = CGRect(x: 10, y: 70, width: 36, height: 20) + let bounds = try renderedBounds( + text: "7.2% · 18.7k / 258.4k 上下文 token", + viewport: CGSize(width: 280, height: 120), + source: source, + placement: .above + ) + #expect(bounds.maxY <= source.minY - 4) + #expect(bounds.minY >= 7) + #expect(bounds.minX >= 7) + #expect(bounds.maxX <= 273) + } + @Test func bottomActivityTooltipKeepsAVerticalMargin() async throws { let bounds = try renderedBounds( diff --git a/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift b/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift index fe4238d2e..07534a5fb 100644 --- a/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift +++ b/macos/Tests/LitheTests/WorkbenchMavenLayoutTests.swift @@ -36,7 +36,7 @@ struct WorkbenchMavenLayoutTests { #expect(restored.mavenPaneWidth == 410) } - @Test(arguments: [-1.0, 0.0, 521.0]) + @Test(arguments: [-1.0, 0.0, 761.0]) func invalidPersistedWidthsFallBackToDefault(width: Double) { let store = WorkbenchLayoutStore(store: MavenLayoutTestStore()) let workspace = URL(fileURLWithPath: "/fixture/maven-layout/project") @@ -79,7 +79,7 @@ struct WorkbenchMavenLayoutTests { #expect(WorkbenchRightToolGeometry.resolvedWidth(restored, in: 1440) == preferred) #expect(WorkbenchRightToolGeometry.committedWidth(380, in: 1440) == 380) #expect(WorkbenchRightToolGeometry.resolvedWidth(10, in: 1440) == 300) - #expect(WorkbenchRightToolGeometry.resolvedWidth(900, in: 1440) == 520) + #expect(WorkbenchRightToolGeometry.resolvedWidth(900, in: 1440) == 760) } } diff --git a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift index 715f0fca3..f9652204d 100644 --- a/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift +++ b/macos/Tests/LitheTests/WorkbenchModuleUIRegistryTests.swift @@ -20,6 +20,16 @@ struct WorkbenchModuleUIRegistryTests { try registry.validate(contributions: contributions) } + @Test func agentConversationIsDockedInTheRightSidebar() throws { + let contributions = BuiltInModuleCatalog.contributions(for: .agentConversation) + let agent = try #require(contributions.first { $0.id == "agent.conversation" }) + let registry = WorkbenchModuleUIComposition.builtIn + + #expect(agent.placement == .rightSidebar) + #expect(registry.renderer(for: agent)?.rightSidebarBehavior == .docked) + try registry.validate(contributions: contributions) + } + @Test func duplicateActionIDsAreRejected() { let first = WorkbenchModuleUIRegistry.Registration(actions: [ .init(id: "test.action", perform: { _ in }) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9abe28488..52c7e7d04 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix 1.1.5", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more 2.1.1", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum", + "tracing", +] + [[package]] name = "ahash" version = "0.7.8" @@ -68,6 +118,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.4.2" @@ -92,6 +148,89 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.92" @@ -161,6 +300,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.12.1", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -197,6 +359,19 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borsh" version = "1.8.0" @@ -309,7 +484,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -349,6 +533,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "combine" version = "4.6.7" @@ -376,11 +571,20 @@ dependencies = [ "jetscii", "phf", "phf_codegen", - "rustc-hash", + "rustc-hash 2.1.3", "smallvec", "typed-arena", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "connection-string" version = "0.2.0" @@ -419,6 +623,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -670,11 +883,34 @@ version = "0.99.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" dependencies = [ - "convert_case", + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -829,6 +1065,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -847,6 +1093,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -889,6 +1141,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -899,6 +1166,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -933,6 +1213,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -962,6 +1255,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1023,6 +1317,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "hashbrown" version = "0.12.3" @@ -1064,6 +1364,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1262,7 +1568,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1425,6 +1731,15 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -1510,6 +1825,19 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kill_tree" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3879339076ac4da142cc852d91693462927cbc99773b5ea422e4834e68c4ff2" +dependencies = [ + "bindgen", + "nix", + "tokio", + "tracing", + "windows", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1519,12 +1847,28 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -1560,12 +1904,40 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lithe-agent-host" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "futures", + "kill_tree", + "libc", + "serde", + "serde_json", + "tokio", + "tokio-util", + "url", + "uuid", +] + [[package]] name = "lithe-core" version = "0.1.0" @@ -1573,6 +1945,7 @@ dependencies = [ "ammonia", "base64 0.22.1", "comrak", + "lithe-agent-host", "lithe-git-host", "quick-xml", "rand 0.8.7", @@ -1746,6 +2119,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1779,7 +2158,7 @@ dependencies = [ "bson", "chrono", "derive-where", - "derive_more", + "derive_more 0.99.20", "futures-core", "futures-executor", "futures-io", @@ -1834,6 +2213,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "libc", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -1999,12 +2399,43 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs1" version = "0.7.5" @@ -2038,6 +2469,20 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -2089,6 +2534,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6fa0831dd7cc608c38a5e323422a0077678fa5744aa2be4ad91c4ece8eec8d5" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -2147,7 +2602,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls 0.23.43", "socket2 0.6.5", "thiserror 2.0.20", @@ -2168,7 +2623,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls 0.23.43", "rustls-pki-types", "slab", @@ -2315,7 +2770,7 @@ dependencies = [ "bytes", "combine", "futures-util", - "itertools", + "itertools 0.13.0", "itoa", "num-bigint", "percent-encoding", @@ -2533,6 +2988,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2558,6 +3019,32 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.21.12" @@ -2699,10 +3186,23 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -2794,6 +3294,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -2897,6 +3408,18 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -3249,6 +3772,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -3472,6 +4016,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2 0.6.5", @@ -3742,6 +4287,18 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -3944,6 +4501,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3960,6 +4529,25 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d131d1d7f..551a50414 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["lithe-core", "lithe-git-host", "lithe-db-sidecar", "lithe-db-mcp"] +members = ["lithe-core", "lithe-git-host", "lithe-db-sidecar", "lithe-db-mcp", "lithe-agent-host"] resolver = "2" [profile.release] diff --git a/rust/lithe-agent-host/Cargo.toml b/rust/lithe-agent-host/Cargo.toml new file mode 100644 index 000000000..f867fdde9 --- /dev/null +++ b/rust/lithe-agent-host/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "lithe-agent-host" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +description = "Shared ACP session and agent process host for Lithe" + +[dependencies] +agent-client-protocol = "=2.2.0" +futures = "0.3" +kill_tree = { version = "0.2", features = ["tokio"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["io-util", "process", "rt", "sync", "time"] } +tokio-util = { version = "0.7", features = ["compat"] } +url = "2" +uuid = { version = "1", features = ["v4"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "test-util"] } diff --git a/rust/lithe-agent-host/src/catalog.rs b/rust/lithe-agent-host/src/catalog.rs new file mode 100644 index 000000000..73418954b --- /dev/null +++ b/rust/lithe-agent-host/src/catalog.rs @@ -0,0 +1,130 @@ +//! Agents Lithe can install and launch, and how each receives its settings. +//! +//! Every adapter signs in through the ACP `gateway` method, so the user's API +//! key travels over stdio in the header of the agent's provider protocol. +//! +//! Entries mirror the official ACP registry +//! (`cdn.agentclientprotocol.com/registry/v1/latest/registry.json`): the same +//! agent ids, npm packages, and pinned versions. A version is raised only after +//! the adapter is re-verified, because each adapter speaks its own dialect of +//! authentication and cancellation. + +use serde::{Deserialize, Serialize}; + +/// Wire protocol of an AI provider, using the platform settings' names. +#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ProviderProtocol { + Responses, + ChatCompletions, + AnthropicMessages, +} + +/// How the provider's model name reaches the agent. It is not secret, so the +/// environment is acceptable for every adapter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ModelDelivery { + /// `CODEX_CONFIG={"model": ...}`, merged into Codex's session config. + CodexConfig, + /// `ANTHROPIC_MODEL`, the Claude adapter's preferred model. + AnthropicEnvironment, +} + +/// The agent's own command-line tool, which the user installs and updates. +/// +/// Adapters that can drive an existing CLI are installed without their bundled +/// copy, so Lithe never downloads an agent the user already has. +#[derive(Debug, PartialEq, Eq)] +pub struct AgentCli { + /// Executable searched on the login shell's `PATH`. + pub command: &'static str, + pub name: &'static str, + /// Lowest version the pinned adapter supports, from its dependency range. + pub minimum_version: &'static str, + /// Environment variable that tells the adapter which executable to run. + pub path_env: &'static str, + /// How users usually install or update the CLI, shown when it is missing. + pub install_hint: &'static str, + /// npm package that provides the CLI, for one-click install or update + /// with the user's npm. + pub package: &'static str, + /// Known Homebrew package names, including supported release channels. + pub brew_packages: &'static [&'static str], +} + +/// One installable ACP adapter distributed as an npm package. +#[derive(Debug, PartialEq, Eq)] +pub struct CatalogAgent { + /// ACP registry id, also the install directory name. + pub id: &'static str, + pub name: &'static str, + pub description: &'static str, + pub package: &'static str, + /// Pinned, verified package version. + pub version: &'static str, + /// Executable the package installs under `node_modules/.bin`. + pub bin: &'static str, + /// Lowest Node.js major version the adapter and its dependencies run on. + pub minimum_node_major: u32, + pub protocol: ProviderProtocol, + pub model_delivery: ModelDelivery, + /// The user's CLI the adapter drives, if it does not bundle one we use. + pub cli: Option, + /// Whether sign-in and a conversation were verified against a real + /// provider with this version. + pub verified: bool, +} + +pub const CATALOG: &[CatalogAgent] = &[ + CatalogAgent { + id: "codex-acp", + name: "Codex", + description: "OpenAI Codex through its ACP adapter", + package: "@agentclientprotocol/codex-acp", + version: "1.13.1", + bin: "codex-acp", + // `open@11`, a dependency of the adapter, requires Node.js 20. + minimum_node_major: 20, + protocol: ProviderProtocol::Responses, + model_delivery: ModelDelivery::CodexConfig, + // `@openai/codex ^0.156.1` in the adapter; its platform binaries are + // optional dependencies that the install skips. + cli: Some(AgentCli { + command: "codex", + name: "Codex CLI", + minimum_version: "0.156.0", + path_env: "CODEX_PATH", + install_hint: "npm install -g @openai/codex", + package: "@openai/codex", + brew_packages: &["codex"], + }), + verified: true, + }, + CatalogAgent { + id: "claude-acp", + name: "Claude", + description: "Claude Agent through its ACP adapter", + package: "@agentclientprotocol/claude-agent-acp", + version: "0.81.2", + bin: "claude-agent-acp", + minimum_node_major: 22, + protocol: ProviderProtocol::AnthropicMessages, + model_delivery: ModelDelivery::AnthropicEnvironment, + // The Claude Agent SDK pairs with Claude Code 2.1.280; its native + // binary is an optional dependency that the install skips. + cli: Some(AgentCli { + command: "claude", + name: "Claude Code", + minimum_version: "2.1.280", + path_env: "CLAUDE_CODE_EXECUTABLE", + install_hint: "npm install -g @anthropic-ai/claude-code", + package: "@anthropic-ai/claude-code", + brew_packages: &["claude-code", "claude-code@latest"], + }), + verified: false, + }, +]; + +pub fn find(id: &str) -> Option<&'static CatalogAgent> { + CATALOG.iter().find(|agent| agent.id == id) +} diff --git a/rust/lithe-agent-host/src/cli_update.rs b/rust/lithe-agent-host/src/cli_update.rs new file mode 100644 index 000000000..0a8ddf90c --- /dev/null +++ b/rust/lithe-agent-host/src/cli_update.rs @@ -0,0 +1,315 @@ +//! Identify the installation owning the active CLI before selecting an updater. +//! Package managers retain their fetching, validation, cache and release channels. + +use std::ffi::OsStr; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::catalog::AgentCli; +use crate::environment::{self, RunError, RuntimeEnvironment}; +use crate::install::ManagementError; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(3); +pub(crate) const MANUAL_HINT: &str = + "Update this CLI using its original installer, then check again."; + +/// Installation provenance of the executable selected by the launch PATH. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum CliSource { + Npm, + Homebrew, + Native, + Missing, + Unknown, +} + +/// Presentation facts; hosts must not execute the displayed update hint. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CliInstallation { + pub source: CliSource, + pub can_update: bool, + pub update_hint: String, +} + +/// An owned executable and arguments, never an interpolated shell command. +#[derive(Debug)] +pub(crate) struct UpdateCommand { + pub program: PathBuf, + pub arguments: Vec, + pub observes_npm: bool, +} + +/// Freshly resolved ownership evidence and the permitted updater, if any. +#[derive(Debug)] +pub(crate) struct UpdatePlan { + pub installation: CliInstallation, + pub command: Option, +} + +impl UpdatePlan { + fn manual(source: CliSource, hint: &str) -> Self { + Self { + installation: CliInstallation { + source, + can_update: false, + update_hint: hint.into(), + }, + command: None, + } + } + + fn automatic( + source: CliSource, + hint: String, + program: &Path, + arguments: Vec, + observes_npm: bool, + ) -> Self { + Self { + installation: CliInstallation { + source, + can_update: true, + update_hint: hint, + }, + command: Some(UpdateCommand { + program: program.into(), + arguments, + observes_npm, + }), + } + } +} + +/// Resolve actual PATH ownership on every click; GUI status is never authority to mutate. +pub(crate) fn detect( + cli: &AgentCli, + runtime: &RuntimeEnvironment, + cancel: &dyn Fn() -> bool, +) -> Result { + if cancel() { + return Err(ManagementError::Cancelled); + } + let path = environment::search_path(); + let executable = environment::find_executable(cli.command, path.as_deref()); + // A broken symlink or non-executable command is still an existing installation. + let existing = executable.or_else(|| { + std::env::split_paths(path.as_deref().unwrap_or(OsStr::new(""))) + .map(|directory| directory.join(cli.command)) + .find(|candidate| std::fs::symlink_metadata(candidate).is_ok()) + }); + let brew = existing + .as_ref() + .and_then(|file| file.parent()) + .and_then(|directory| std::env::join_paths([directory]).ok()) + .and_then(|path| environment::find_executable("brew", Some(&path))) + .or_else(|| environment::find_executable("brew", path.as_deref())); + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from); + resolve( + cli, + existing.as_deref(), + runtime.npm.as_ref().map(|tool| tool.path.as_path()), + brew.as_deref(), + home.as_deref(), + &|program, args| { + let mut command = Command::new(program); + command + .args(args) + .env("HOMEBREW_NO_AUTO_UPDATE", "1") + .env("HOMEBREW_NO_ANALYTICS", "1"); + if let Some(path) = &path { + command.env("PATH", path); + } + environment::run_bounded(command, PROBE_TIMEOUT, cancel) + }, + ) +} + +fn query( + probe: &dyn Fn(&Path, &[&str]) -> Result, + program: &Path, + args: &[&str], +) -> Result, ManagementError> { + match probe(program, args) { + Ok(output) => Ok(Some(output.trim().into())), + Err(RunError::Start(_)) => Ok(None), + Err(RunError::Cancelled) => Err(ManagementError::Cancelled), + Err(RunError::TimedOut) => Err(ManagementError::TimedOut), + } +} + +/// Filesystem identity and read-only manager probes are injected for deterministic tests. +fn resolve( + cli: &AgentCli, + executable: Option<&Path>, + npm: Option<&Path>, + brew: Option<&Path>, + home: Option<&Path>, + probe: &dyn Fn(&Path, &[&str]) -> Result, +) -> Result { + let target = executable.and_then(|path| path.canonicalize().ok()); + if executable.is_some() && target.is_none() { + return Ok(UpdatePlan::manual(CliSource::Unknown, MANUAL_HINT)); + } + if let (Some(target), Some(brew)) = (&target, brew) { + for (root_option, kind) in [("--caskroom", "--cask"), ("--cellar", "--formula")] { + let Some(root) = query(probe, brew, &[root_option])? + .and_then(|root| Path::new(&root).canonicalize().ok()) + else { + continue; + }; + let Some(package) = target + .strip_prefix(&root) + .ok() + .and_then(|relative| relative.components().next()) + .and_then(|component| component.as_os_str().to_str()) + else { + continue; + }; + if !cli.brew_packages.contains(&package) { + continue; + } + let installed = query(probe, brew, &["list", kind, "--versions", package])?; + if installed.as_deref().is_some_and(|output| { + output.lines().any(|line| { + let mut fields = line.split_whitespace(); + fields.next() == Some(package) && fields.next().is_some() + }) + }) { + return Ok(UpdatePlan::automatic( + CliSource::Homebrew, + format!("brew upgrade {kind} {package}"), + brew, + vec!["upgrade".into(), kind.into(), package.into()], + false, + )); + } + // A manager-owned path without its receipt must never fall back to npm. + return Ok(UpdatePlan::manual(CliSource::Homebrew, MANUAL_HINT)); + } + } + if let (Some(executable), Some(target), Some(home)) = (executable, &target, home) { + let launcher = home.join(".local/bin/claude"); + let versions = home + .join(".local/share/claude/versions") + .canonicalize() + .ok(); + if cli.command == "claude" + && executable == launcher + && std::fs::read_link(executable).is_ok() + && versions.is_some_and(|versions| target.parent() == Some(versions.as_path())) + { + return Ok(UpdatePlan::automatic( + CliSource::Native, + "claude update".into(), + executable, + vec!["update".into()], + false, + )); + } + } + let npm_owned = target.as_ref().is_some_and(|target| { + target.ancestors().any(|root| { + root.file_name().is_some_and(|name| name == "node_modules") + && owns_npm_bin(&root.join(cli.package), cli, target) + }) + }); + let source = if executable.is_none() { + CliSource::Missing + } else if npm_owned { + CliSource::Npm + } else { + CliSource::Unknown + }; + if executable.is_some() && !npm_owned { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + } + let Some(npm) = npm else { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + }; + let Some(prefix) = query(probe, npm, &["prefix", "--global"])? else { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + }; + let prefix = PathBuf::from(prefix); + if !prefix.is_absolute() { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + } + let bin = if cfg!(windows) { + prefix.join(format!("{}.cmd", cli.command)) + } else { + prefix.join("bin").join(cli.command) + }; + if let Some(target) = &target { + let Some(root) = query(probe, npm, &["root", "--global"])? else { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + }; + let Ok(root) = Path::new(&root).canonicalize() else { + return Ok(UpdatePlan::manual(source, MANUAL_HINT)); + }; + let package = root.join(cli.package); + if !package + .canonicalize() + .is_ok_and(|package| package.starts_with(&root)) + || !owns_npm_bin(&package, cli, target) + || bin.canonicalize().ok().as_ref() != Some(target) + { + return Ok(UpdatePlan::manual(CliSource::Npm, + "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again.")); + } + } else if std::fs::symlink_metadata(&bin).is_ok() { + return Ok(UpdatePlan::manual(CliSource::Unknown, MANUAL_HINT)); + } + Ok(UpdatePlan::automatic( + source, + cli.install_hint.into(), + npm, + vec![ + "install".into(), + "-g".into(), + "--no-audit".into(), + "--no-fund".into(), + "--loglevel=error".into(), + format!("{}@latest", cli.package), + ], + true, + )) +} + +fn owns_npm_bin(package: &Path, cli: &AgentCli, target: &Path) -> bool { + let manifest = (|| { + let file = std::fs::File::open(package.join("package.json")).ok()?; + let mut bytes = Vec::new(); + file.take(64 * 1024).read_to_end(&mut bytes).ok()?; + serde_json::from_slice::(&bytes).ok() + })(); + let Some(manifest) = manifest else { + return false; + }; + if manifest["name"] != cli.package { + return false; + } + let Some(bin) = manifest["bin"] + .get(cli.command) + .unwrap_or(&manifest["bin"]) + .as_str() + else { + return false; + }; + let Ok(package_root) = package.canonicalize() else { + return false; + }; + package + .join(bin) + .canonicalize() + .is_ok_and(|bin| bin.starts_with(package_root) && bin == target) +} + +#[cfg(all(test, unix))] +mod tests; diff --git a/rust/lithe-agent-host/src/cli_update/tests.rs b/rust/lithe-agent-host/src/cli_update/tests.rs new file mode 100644 index 000000000..a25462efc --- /dev/null +++ b/rust/lithe-agent-host/src/cli_update/tests.rs @@ -0,0 +1,268 @@ +use super::*; +use std::os::unix::fs::{symlink, PermissionsExt}; + +struct Fixture(PathBuf); +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!("lithe-cli-owner-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + fn file(&self, relative: &str, contents: &str) -> PathBuf { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, contents).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + path + } + fn link(&self, relative: &str, target: &Path) -> PathBuf { + let path = self.0.join(relative); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + symlink(target, &path).unwrap(); + path + } + fn npm_cli(&self) -> PathBuf { + let target = self.file( + "npm space/node_modules/@openai/codex/bin/codex.js", + "#!/bin/sh\n", + ); + self.file( + "npm space/node_modules/@openai/codex/package.json", + r#"{"name":"@openai/codex","bin":{"codex":"bin/codex.js"}}"#, + ); + self.link("npm space/bin/codex", &target) + } + fn npm_probe(&self, _: &Path, args: &[&str]) -> Result { + match args { + ["prefix", "--global"] => Ok(self.0.join("npm space").display().to_string()), + ["root", "--global"] => Ok(self.0.join("npm space/node_modules").display().to_string()), + _ => panic!("unexpected probe: {args:?}"), + } + } +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} +fn codex() -> &'static AgentCli { + crate::catalog::find("codex-acp") + .unwrap() + .cli + .as_ref() + .unwrap() +} +fn claude() -> &'static AgentCli { + crate::catalog::find("claude-acp") + .unwrap() + .cli + .as_ref() + .unwrap() +} + +#[test] +fn homebrew_receipt_and_actual_target_win_over_an_unrelated_npm_install() { + // Preserve cask/formula ownership and Claude's chosen release channel. + for (kind, directory, package, cli) in [ + ("--cask", "Caskroom", "codex", codex()), + ("--formula", "Cellar", "codex", codex()), + ("--cask", "Caskroom", "claude-code@latest", claude()), + ] { + let fixture = Fixture::new(); + let npm = fixture.file("npm space/bin/npm", "#!/bin/sh\n"); + fixture.npm_cli(); + let brew = fixture.file("brew prefix/bin/brew", "#!/bin/sh\n"); + let target = fixture.file( + &format!("brew prefix/{directory}/{package}/1/cli"), + "#!/bin/sh\n", + ); + let executable = fixture.link(&format!("brew prefix/bin/{}", cli.command), &target); + std::fs::create_dir_all(fixture.0.join("brew prefix/Cellar")).unwrap(); + std::fs::create_dir_all(fixture.0.join("brew prefix/Caskroom")).unwrap(); + let plan = resolve( + cli, + Some(&executable), + Some(&npm), + Some(&brew), + None, + &|program, args| { + assert_eq!( + program, brew, + "npm must not be consulted for a verified Homebrew CLI" + ); + match args { + ["--caskroom"] => { + Ok(fixture.0.join("brew prefix/Caskroom").display().to_string()) + } + ["--cellar"] => Ok(fixture.0.join("brew prefix/Cellar").display().to_string()), + ["list", flag, "--versions", name] if *flag == kind && *name == package => { + Ok(format!("{package} 1")) + } + _ => panic!("unexpected probe: {args:?}"), + } + }, + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Homebrew); + assert!(plan.installation.can_update); + let command = plan.command.unwrap(); + assert_eq!(command.program, brew); + assert_eq!(command.arguments, ["upgrade", kind, package]); + assert!(!command.observes_npm); + } +} + +#[test] +fn npm_requires_its_global_package_manifest_bin_and_active_link_to_agree() { + let fixture = Fixture::new(); + let executable = fixture.npm_cli(); + let npm = fixture.file("npm space/bin/npm", "#!/bin/sh\n"); + let plan = resolve( + codex(), + Some(&executable), + Some(&npm), + None, + None, + &|program, args| fixture.npm_probe(program, args), + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Npm); + assert!(plan.installation.can_update); + assert_eq!(plan.command.unwrap().program, npm); + + fixture.file( + "npm space/node_modules/@openai/codex/package.json", + r#"{"name":"different-package","bin":{"codex":"bin/codex.js"}}"#, + ); + let plan = resolve( + codex(), + Some(&executable), + Some(&npm), + None, + None, + &|_, _| panic!("unverified packages must not be updated"), + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Unknown); + assert!(plan.command.is_none()); +} + +#[test] +fn another_node_environment_cannot_update_the_active_npm_install() { + let fixture = Fixture::new(); + let executable = fixture.npm_cli(); + let npm = fixture.file("other-node/bin/npm", "#!/bin/sh\n"); + std::fs::create_dir_all(fixture.0.join("other-node/node_modules")).unwrap(); + let plan = resolve( + codex(), + Some(&executable), + Some(&npm), + None, + None, + &|_, args| { + Ok(fixture + .0 + .join(if args[0] == "prefix" { + "other-node" + } else { + "other-node/node_modules" + }) + .display() + .to_string()) + }, + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Npm); + assert!(!plan.installation.can_update); + assert!(plan.command.is_none()); + assert!(plan + .installation + .update_hint + .contains("Node.js/npm environment")); +} + +#[test] +fn native_claude_launcher_uses_self_update_without_requiring_npm() { + let fixture = Fixture::new(); + let target = fixture.file("home/.local/share/claude/versions/2.1.280", "#!/bin/sh\n"); + let executable = fixture.link("home/.local/bin/claude", &target); + let plan = resolve( + claude(), + Some(&executable), + None, + None, + Some(&fixture.0.join("home")), + &|_, _| panic!("native installation does not need package manager probes"), + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Native); + let command = plan.command.unwrap(); + assert_eq!(command.program, executable); + assert_eq!(command.arguments, ["update"]); +} + +#[test] +fn missing_cli_can_use_npm_but_existing_or_broken_commands_are_never_overwritten() { + let fixture = Fixture::new(); + let npm = fixture.file("npm space/bin/npm", "#!/bin/sh\n"); + let plan = resolve(codex(), None, Some(&npm), None, None, &|p, a| { + fixture.npm_probe(p, a) + }) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Missing); + assert!(plan.installation.can_update); + + let broken = fixture.link("npm space/bin/codex", &fixture.0.join("missing-target")); + for executable in [None, Some(broken.as_path())] { + let plan = resolve(codex(), executable, Some(&npm), None, None, &|p, a| { + fixture.npm_probe(p, a) + }) + .unwrap(); + assert!(!plan.installation.can_update); + assert!(plan.command.is_none()); + } + let manual = fixture.file("manual/codex", "#!/bin/sh\n"); + let plan = resolve(codex(), Some(&manual), Some(&npm), None, None, &|_, _| { + panic!("do not probe/update unknown owners") + }) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Unknown); + assert!(plan.command.is_none()); +} + +#[test] +fn unrecorded_homebrew_target_cannot_fall_back_to_npm_and_cancel_is_preserved() { + let fixture = Fixture::new(); + let target = fixture.file("Caskroom/codex/1/cli", "#!/bin/sh\n"); + let brew = fixture.file("bin/brew", "#!/bin/sh\n"); + let plan = resolve( + codex(), + Some(&target), + Some(Path::new("unused-npm")), + Some(&brew), + None, + &|_, args| { + if args[0] == "--caskroom" { + Ok(fixture.0.join("Caskroom").display().to_string()) + } else { + Err(RunError::Start("no installed receipt".into())) + } + }, + ) + .unwrap(); + assert_eq!(plan.installation.source, CliSource::Homebrew); + assert!(plan.command.is_none()); + for (failure, expected) in [ + (RunError::Cancelled, ManagementError::Cancelled), + (RunError::TimedOut, ManagementError::TimedOut), + ] { + let result = resolve(codex(), Some(&target), None, Some(&brew), None, &|_, _| { + Err(if failure == RunError::Cancelled { + RunError::Cancelled + } else { + RunError::TimedOut + }) + }); + assert_eq!(result.unwrap_err(), expected); + } +} diff --git a/rust/lithe-agent-host/src/environment.rs b/rust/lithe-agent-host/src/environment.rs new file mode 100644 index 000000000..938730541 --- /dev/null +++ b/rust/lithe-agent-host/src/environment.rs @@ -0,0 +1,509 @@ +//! Detection of the user-installed runtime that npm-distributed agents need. +//! +//! Lithe never installs Node.js; it only reports what it finds. GUI apps on +//! macOS do not inherit the login shell's `PATH`, so version managers such as +//! nvm or fnm are invisible unless the shell is asked for its `PATH`. + +use std::ffi::OsString; +use std::io::{BufRead, BufReader, Read}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use serde::Serialize; + +const SHELL_TIMEOUT: Duration = Duration::from_secs(10); +const VERSION_TIMEOUT: Duration = Duration::from_secs(10); +const POLL_INTERVAL: Duration = Duration::from_millis(50); +/// How long to wait for output after the child exited or was killed. +const OUTPUT_GRACE: Duration = Duration::from_secs(2); +const PATH_START: &str = "__LITHE_PATH_START__"; +const PATH_END: &str = "__LITHE_PATH_END__"; + +/// Most recent search path, refreshed by [`detect`] and reused by launches. +static SEARCH_PATH: Mutex> = Mutex::new(None); + +/// A tool found on the search path. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DetectedTool { + pub version: String, + pub path: PathBuf, +} + +/// Node.js and npm as seen by agent launches and installs. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RuntimeEnvironment { + pub node: Option, + pub npm: Option, + /// Whether the login shell reported a `PATH`; false means only the app's + /// own `PATH` was searched. + pub used_login_shell: bool, +} + +impl RuntimeEnvironment { + /// Major version of the detected Node.js, e.g. 22 for `v22.3.1`. + pub fn node_major(&self) -> Option { + self.node + .as_ref() + .and_then(|node| parse_major(&node.version)) + } +} + +/// Failure of a bounded child process. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum RunError { + Start(String), + TimedOut, + Cancelled, +} + +/// Detect Node.js and npm, refreshing the cached search path. +pub fn detect(cancel: &dyn Fn() -> bool) -> RuntimeEnvironment { + let shell_path = login_shell_path(cancel); + let used_login_shell = shell_path.is_some(); + let path = merge_paths(shell_path, std::env::var_os("PATH")); + if let Ok(mut cached) = SEARCH_PATH.lock() { + *cached = path.clone(); + } + let tool = |name: &str| { + let executable = find_executable(name, path.as_deref())?; + let version = tool_version(&executable, path.as_deref(), cancel)?; + Some(DetectedTool { + version, + path: executable, + }) + }; + RuntimeEnvironment { + node: tool("node"), + npm: tool("npm"), + used_login_shell, + } +} + +/// Search path for launching agents, detecting it on first use. +pub fn search_path() -> Option { + search_path_with_cancel(&|| false) +} + +/// Search path for launching agents while allowing first-use detection to stop. +pub fn search_path_with_cancel(cancel: &dyn Fn() -> bool) -> Option { + if let Some(path) = SEARCH_PATH.lock().ok().and_then(|cached| cached.clone()) { + return Some(path); + } + detect(cancel); + SEARCH_PATH.lock().ok().and_then(|cached| cached.clone()) +} + +/// `PATH` reported by the user's interactive login shell (Unix only). +fn login_shell_path(cancel: &dyn Fn() -> bool) -> Option { + if cfg!(windows) { + return None; + } + let shell = std::env::var_os("SHELL") + .filter(|shell| !shell.is_empty()) + .unwrap_or_else(|| OsString::from("/bin/sh")); + let is_fish = Path::new(&shell) + .file_name() + .is_some_and(|name| name == "fish"); + // Markers separate the value from greetings printed by shell startup files. + let script = if is_fish { + format!("printf '{PATH_START}%s{PATH_END}' (string join : $PATH)") + } else { + format!("printf '{PATH_START}%s{PATH_END}' \"$PATH\"") + }; + let mut command = Command::new(&shell); + command.args(["-i", "-l", "-c", &script]); + let output = run_bounded(command, SHELL_TIMEOUT, cancel).ok()?; + parse_marked_path(&output) +} + +fn parse_marked_path(output: &str) -> Option { + let start = output.rfind(PATH_START)? + PATH_START.len(); + let end = start + output[start..].find(PATH_END)?; + let path = output[start..end].trim(); + (!path.is_empty()).then(|| OsString::from(path)) +} + +/// Shell entries first, then the app's own entries not already present. +fn merge_paths(first: Option, second: Option) -> Option { + let mut entries: Vec = Vec::new(); + for path in [first, second].into_iter().flatten() { + for entry in std::env::split_paths(&path) { + if !entry.as_os_str().is_empty() && !entries.contains(&entry) { + entries.push(entry); + } + } + } + (!entries.is_empty()) + .then(|| std::env::join_paths(entries).ok()) + .flatten() +} + +/// First `name` executable on `path`; on Windows also `name.cmd` and `name.exe`. +pub(crate) fn find_executable(name: &str, path: Option<&std::ffi::OsStr>) -> Option { + let candidates: &[&str] = if cfg!(windows) { + &[".exe", ".cmd", ""] + } else { + &[""] + }; + std::env::split_paths(path?).find_map(|directory| { + candidates + .iter() + .map(|suffix| directory.join(format!("{name}{suffix}"))) + .find(|candidate| is_executable(candidate)) + }) +} + +fn is_executable(path: &Path) -> bool { + let Ok(metadata) = std::fs::metadata(path) else { + return false; + }; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + metadata.is_file() && metadata.permissions().mode() & 0o111 != 0 + } + #[cfg(not(unix))] + { + metadata.is_file() + } +} + +fn tool_version( + executable: &Path, + path: Option<&std::ffi::OsStr>, + cancel: &dyn Fn() -> bool, +) -> Option { + let mut command = Command::new(executable); + command.arg("--version"); + if let Some(path) = path { + command.env("PATH", path); + } + let output = run_bounded(command, VERSION_TIMEOUT, cancel).ok()?; + let line = output + .lines() + .map(str::trim) + .find(|line| !line.is_empty())?; + // `v22.3.1`, `10.2.4`, or a named banner such as `codex-cli 0.156.1`. + let version = line + .split_whitespace() + .map(|token| token.trim_start_matches('v')) + .find(|token| token.starts_with(|c: char| c.is_ascii_digit())) + .unwrap_or(line); + Some(version.to_owned()) +} + +/// Find `command` on the agent search path and read its version. +pub fn detect_tool(command: &str, cancel: &dyn Fn() -> bool) -> Option { + let path = search_path_with_cancel(cancel); + let executable = find_executable(command, path.as_deref())?; + let version = tool_version(&executable, path.as_deref(), cancel)?; + Some(DetectedTool { + version, + path: executable, + }) +} + +/// Whether dotted `version` is at least `minimum`, comparing numeric parts; +/// pre-release suffixes such as `-alpha.1` are ignored. +pub fn version_at_least(version: &str, minimum: &str) -> bool { + let parts = |value: &str| -> Vec { + value + .trim() + .trim_start_matches('v') + .split(['-', '+']) + .next() + .unwrap_or_default() + .split('.') + .map(|part| part.parse().unwrap_or(0)) + .collect() + }; + let (version, minimum) = (parts(version), parts(minimum)); + for index in 0..version.len().max(minimum.len()) { + let (left, right) = ( + version.get(index).copied().unwrap_or(0), + minimum.get(index).copied().unwrap_or(0), + ); + if left != right { + return left > right; + } + } + true +} + +/// Major version of `22.3.1` or `v22.3.1`. +pub fn parse_major(version: &str) -> Option { + version + .trim() + .trim_start_matches('v') + .split('.') + .next()? + .parse() + .ok() +} + +/// Run a child to completion within `timeout`, returning combined output. +/// +/// Output is collected on reader threads so a chatty child cannot block on a +/// full pipe. The child is killed on timeout or cancellation. +pub(crate) fn run_bounded( + mut command: Command, + timeout: Duration, + cancel: &dyn Fn() -> bool, +) -> Result { + let (status, output) = run_bounded_status(&mut command, timeout, cancel)?; + if status { + Ok(output) + } else { + Err(RunError::Start(output)) + } +} + +/// Like [`run_bounded`], but returns whether the child succeeded with its output. +pub(crate) fn run_bounded_status( + command: &mut Command, + timeout: Duration, + cancel: &dyn Fn() -> bool, +) -> Result<(bool, String), RunError> { + run_bounded_status_observed(command, timeout, cancel, &mut |_| {}) +} + +/// Streams bounded output lines on the caller thread while retaining a diagnostic tail. +/// Observers must not block; their lifetime ends before this function returns. +pub(crate) fn run_bounded_status_observed( + command: &mut Command, + timeout: Duration, + cancel: &dyn Fn() -> bool, + observe: &mut dyn FnMut(&str), +) -> Result<(bool, String), RunError> { + command + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // Keep the child in its own group so a terminal-reading shell cannot + // stop Lithe with SIGTTIN, and so the whole tree can be killed. + command.process_group(0); + } + let mut child = command + .spawn() + .map_err(|error| RunError::Start(error.to_string()))?; + let (output_tx, output_rx) = std::sync::mpsc::sync_channel::>>(32); + let readers: Vec<_> = [ + child + .stdout + .take() + .map(|p| Box::new(p) as Box), + child + .stderr + .take() + .map(|p| Box::new(p) as Box), + ] + .into_iter() + .flatten() + .map(|pipe| { + let sender = output_tx.clone(); + std::thread::spawn(move || { + let mut reader = BufReader::new(pipe); + // Bound individual lines even if a child never writes a newline. + loop { + let mut bytes = Vec::new(); + let read = std::io::Read::by_ref(&mut reader) + .take(8192) + .read_until(b'\n', &mut bytes); + match read { + Ok(0) | Err(_) => break, + Ok(_) if sender.send(Some(bytes)).is_err() => return, + _ => {} + } + } + let _ = sender.send(None); + }) + }) + .collect(); + let reader_count = readers.len(); + drop(output_tx); + let mut output = String::new(); + let ended = std::cell::Cell::new(0); + let mut collect = |event: Option>| { + if let Some(bytes) = event { + let text = String::from_utf8_lossy(&bytes); + observe(&text); + output.push_str(&text); + if output.len() > 65536 { + let mut first = output.len() - 65536; + while !output.is_char_boundary(first) { + first += 1; + } + output.drain(..first); + } + } else { + ended.set(ended.get() + 1); + } + }; + let deadline = Instant::now() + timeout; + let outcome = loop { + // A chatty process must not starve cancellation or its deadline. + for _ in 0..32 { + match output_rx.try_recv() { + Ok(event) => collect(event), + Err(_) => break, + } + } + match child.try_wait() { + Ok(Some(status)) => break Ok(status.success()), + Ok(None) if cancel() => break Err(RunError::Cancelled), + Ok(None) if Instant::now() >= deadline => break Err(RunError::TimedOut), + Ok(None) => std::thread::sleep(POLL_INTERVAL), + Err(error) => break Err(RunError::Start(error.to_string())), + } + }; + // Reap the direct child and terminate descendants on every path. A process + // that exits successfully can still leave a child holding stdout/stderr. + crate::force_kill_tree(child.id()); + let _ = child.wait(); + // A descendant that escaped the process group can still hold a pipe open; + // do not let it block the caller past a short grace period. + let output_deadline = Instant::now() + OUTPUT_GRACE; + while ended.get() < reader_count { + match output_rx.recv_timeout(output_deadline.saturating_duration_since(Instant::now())) { + Ok(event) => { + collect(event); + } + Err(_) => break, + } + } + drop(collect); + // The process-group cleanup above should close every pipe. Joining keeps + // successful, failed, cancelled and timed-out calls from leaking reader + // threads; the bounded grace period still protects the caller from a + // descendant that deliberately escaped the group. + for reader in readers { + let _ = reader.join(); + } + outcome.map(|success| (success, output)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marked_path_ignores_shell_greetings() { + let output = format!("Welcome!\n{PATH_START}/opt/node/bin:/usr/bin{PATH_END}\nbye"); + assert_eq!( + parse_marked_path(&output), + Some(OsString::from("/opt/node/bin:/usr/bin")) + ); + assert_eq!(parse_marked_path("no markers"), None); + assert_eq!(parse_marked_path(&format!("{PATH_START}{PATH_END}")), None); + } + + #[test] + fn merged_path_keeps_shell_order_without_duplicates() { + let merged = merge_paths( + Some(std::env::join_paths(["/shell/bin", "/usr/bin"]).unwrap()), + Some(std::env::join_paths(["/usr/bin", "/app/bin"]).unwrap()), + ) + .unwrap(); + let entries: Vec = std::env::split_paths(&merged).collect(); + assert_eq!( + entries, + [ + PathBuf::from("/shell/bin"), + "/usr/bin".into(), + "/app/bin".into() + ] + ); + assert_eq!(merge_paths(None, None), None); + } + + #[test] + fn versions_compare_numerically() { + assert!(version_at_least("0.156.1", "0.156.0")); + assert!(version_at_least("0.160.0", "0.156.0")); + assert!(version_at_least("1.0.0", "0.156.0")); + assert!(!version_at_least("0.99.9", "0.156.0")); + assert!(!version_at_least("0.155.9-alpha.1", "0.156.0")); + assert!(version_at_least("v22", "22.0.0")); + } + + #[test] + fn major_versions_parse_with_or_without_prefix() { + assert_eq!(parse_major("v22.3.1"), Some(22)); + assert_eq!(parse_major("20.11.0"), Some(20)); + assert_eq!(parse_major("latest"), None); + } + + #[cfg(unix)] + #[test] + fn bounded_run_reports_timeout_and_cancellation() { + let sleeper = || { + let mut command = Command::new("sh"); + command.args(["-c", "sleep 30"]); + command + }; + let started = Instant::now(); + assert_eq!( + run_bounded(sleeper(), Duration::from_millis(200), &|| false), + Err(RunError::TimedOut) + ); + assert_eq!( + run_bounded(sleeper(), Duration::from_secs(30), &|| true), + Err(RunError::Cancelled) + ); + assert!( + started.elapsed() < Duration::from_secs(10), + "child was killed" + ); + let mut echo = Command::new("sh"); + echo.args(["-c", "echo out; echo err >&2"]); + let output = run_bounded(echo, Duration::from_secs(10), &|| false).unwrap(); + assert!(output.contains("out") && output.contains("err")); + } + + #[cfg(unix)] + #[test] + fn parent_exit_closes_descendant_pipes_before_returning() { + let mut command = Command::new("sh"); + command.args(["-c", "(sleep 30) & printf parent; exit 0"]); + let started = Instant::now(); + let output = run_bounded(command, Duration::from_secs(5), &|| false).unwrap(); + assert!(output.contains("parent")); + assert!( + started.elapsed() < Duration::from_secs(5), + "reader cleanup is bounded" + ); + } + + #[cfg(unix)] + #[test] + fn executables_are_found_only_when_runnable() { + use std::os::unix::fs::PermissionsExt; + let directory = std::env::temp_dir().join(format!("lithe-env-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let tool = directory.join("lithe-tool"); + std::fs::write(&tool, "#!/bin/sh\necho lithe-tool v1.2.3\n").unwrap(); + let path = OsString::from(directory.as_os_str()); + assert_eq!( + find_executable("lithe-tool", Some(&path)), + None, + "not executable yet" + ); + std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert_eq!( + find_executable("lithe-tool", Some(&path)), + Some(tool.clone()) + ); + assert_eq!( + tool_version(&tool, Some(&path), &|| false).as_deref(), + Some("1.2.3") + ); + let _ = std::fs::remove_dir_all(&directory); + } +} diff --git a/rust/lithe-agent-host/src/install.rs b/rust/lithe-agent-host/src/install.rs new file mode 100644 index 000000000..42aea49e4 --- /dev/null +++ b/rust/lithe-agent-host/src/install.rs @@ -0,0 +1,964 @@ +//! One-click installation of ACP adapters with the user's own npm. +//! +//! Each adapter lives in `/agents/` with a `lithe-agent.json` marker +//! naming the installed version. Installs run in a staging directory that +//! replaces the old one only after npm succeeded and the executable exists, so +//! a failed or cancelled install never breaks a working adapter. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::catalog::{AgentCli, CatalogAgent, ProviderProtocol, CATALOG}; +use crate::cli_update::{self, CliInstallation, CliSource}; +use crate::environment::{self, DetectedTool, RunError, RuntimeEnvironment}; + +const INSTALL_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const MARKER: &str = "lithe-agent.json"; +/// Characters of npm output kept for a failure report. +const OUTPUT_TAIL: usize = 2000; + +// Install and uninstall share fixed per-agent paths. Serialize mutations in +// this host process so one request cannot remove or replace another request's +// staging directory or rollback marker. +static INSTALL_MUTEX: OnceLock> = OnceLock::new(); + +fn install_lock() -> std::sync::MutexGuard<'static, ()> { + INSTALL_MUTEX + .get_or_init(|| Mutex::new(())) + .lock() + .expect("agent install lock should not be poisoned") +} + +/// Verified CLI version, with bounded installer diagnostics when it reported failure. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CliUpdateResult { + pub cli_version: String, + /// A warning only after a newly installed or strictly newer usable CLI is verified. + pub updater_warning: Option, +} + +/// Why a management request failed; hosts map these to stable error codes. +#[derive(Debug, PartialEq, Eq)] +pub enum ManagementError { + UnknownAgent(String), + RuntimeMissing(String), + Failed(String), + Cancelled, + TimedOut, +} + +impl ManagementError { + pub fn message(&self) -> String { + match self { + Self::UnknownAgent(id) => format!("Unknown agent `{id}`"), + Self::RuntimeMissing(message) | Self::Failed(message) => message.clone(), + Self::Cancelled => "The installation was cancelled".into(), + Self::TimedOut => "The installation did not finish in time".into(), + } + } +} + +#[derive(Debug, Deserialize, Serialize)] +struct Marker { + id: String, + version: String, +} + +/// The user's own agent CLI that an adapter drives. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CliStatus { + pub name: String, + pub command: String, + pub minimum_version: String, + pub install_hint: String, + /// npm package Lithe installs globally on request. + pub package: String, + pub detected: Option, + pub installation: Option, +} + +/// Status of one catalog agent on this machine. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentStatus { + pub id: String, + pub name: String, + pub description: String, + pub package: String, + /// Version Lithe installs. + pub version: String, + pub installed_version: Option, + pub protocol: ProviderProtocol, + pub minimum_node_major: u32, + pub verified: bool, + /// The user's CLI this adapter runs, when it does not bundle one. + pub cli: Option, + /// User-facing reasons the agent cannot be installed or started now. + pub issues: Vec, +} + +/// Runtime detection plus every catalog agent's status. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ManagementStatus { + pub environment: RuntimeEnvironment, + pub agents: Vec, +} + +/// Detect the runtime and report each catalog agent's installation state. +pub fn status(data_directory: &Path, cancel: &dyn Fn() -> bool) -> ManagementStatus { + let environment = environment::detect(cancel); + status_with( + data_directory, + environment.clone(), + &|command| environment::detect_tool(command, cancel), + &|cli| { + Some( + cli_update::detect(cli, &environment, cancel) + .map(|plan| plan.installation) + .unwrap_or_else(|_| CliInstallation { + source: CliSource::Unknown, + can_update: false, + update_hint: cli_update::MANUAL_HINT.into(), + }), + ) + }, + ) +} + +/// Catalog statuses for an already detected runtime; `find_cli` looks up an +/// agent CLI on the search path and `find_installation` supplies ownership evidence. +pub(crate) fn status_with( + data_directory: &Path, + environment: RuntimeEnvironment, + find_cli: &dyn Fn(&str) -> Option, + find_installation: &dyn Fn(&AgentCli) -> Option, +) -> ManagementStatus { + let agents = CATALOG + .iter() + .map(|agent| { + let cli = agent.cli.as_ref().map(|cli| CliStatus { + name: cli.name.into(), + command: cli.command.into(), + minimum_version: cli.minimum_version.into(), + install_hint: cli.install_hint.into(), + package: cli.package.into(), + detected: find_cli(cli.command), + installation: find_installation(cli), + }); + let mut issues = runtime_issues(agent, &environment); + if let (Some(cli), Some(status)) = (&agent.cli, &cli) { + issues.extend(cli_issue(cli, status.detected.as_ref())); + } + AgentStatus { + id: agent.id.into(), + name: agent.name.into(), + description: agent.description.into(), + package: agent.package.into(), + version: agent.version.into(), + installed_version: installed_version(data_directory, agent), + protocol: agent.protocol, + minimum_node_major: agent.minimum_node_major, + verified: agent.verified, + cli, + issues, + } + }) + .collect(); + ManagementStatus { + environment, + agents, + } +} + +/// Why the user's CLI cannot be used, if it is missing or too old. +pub(crate) fn cli_issue(cli: &AgentCli, detected: Option<&DetectedTool>) -> Option { + match detected { + None => Some(format!( + "{} was not found. Install it (for example `{}`), then check again.", + cli.name, cli.install_hint + )), + Some(tool) if !environment::version_at_least(&tool.version, cli.minimum_version) => { + Some(format!( + "{} {} or later is required; found {}. Update it, then check again.", + cli.name, cli.minimum_version, tool.version + )) + } + Some(_) => None, + } +} + +fn runtime_issues(agent: &CatalogAgent, environment: &RuntimeEnvironment) -> Vec { + let mut issues = Vec::new(); + match environment.node_major() { + None => issues.push(format!( + "Node.js {} or later was not found. Install it, then check again.", + agent.minimum_node_major + )), + Some(major) if major < agent.minimum_node_major => issues.push(format!( + "{} needs Node.js {} or later; found {}.", + agent.name, + agent.minimum_node_major, + environment + .node + .as_ref() + .map_or("", |node| node.version.as_str()) + )), + Some(_) => {} + } + if environment.npm.is_none() { + issues.push("npm was not found. It is normally installed with Node.js.".into()); + } + issues +} + +/// Installed adapter directory for `agent`. +pub fn agent_directory(data_directory: &Path, agent: &CatalogAgent) -> PathBuf { + data_directory.join("agents").join(agent.id) +} + +/// Executable of an installed adapter. +pub fn installed_command(data_directory: &Path, agent: &CatalogAgent) -> PathBuf { + bin_path(&agent_directory(data_directory, agent), agent) +} + +fn bin_path(root: &Path, agent: &CatalogAgent) -> PathBuf { + let name = if cfg!(windows) { + format!("{}.cmd", agent.bin) + } else { + agent.bin.to_owned() + }; + root.join("node_modules").join(".bin").join(name) +} + +/// Version recorded by a complete install whose executable still exists. +pub fn installed_version(data_directory: &Path, agent: &CatalogAgent) -> Option { + let root = agent_directory(data_directory, agent); + let marker: Marker = serde_json::from_slice(&std::fs::read(root.join(MARKER)).ok()?).ok()?; + (marker.id == agent.id && bin_path(&root, agent).exists()).then_some(marker.version) +} + +/// Live npm transfer counters. The package set and its total size are not known +/// in advance, so these counters intentionally do not claim an overall percentage. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct InstallProgress { + pub stage: InstallStage, + pub downloaded_bytes: u64, + pub bytes_per_second: u64, + pub elapsed_milliseconds: u64, + /// Time since the last received package bytes; not an install timeout. + pub idle_milliseconds: u64, +} + +/// Observable npm stage, independent of whether the overall install succeeded. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum InstallStage { + Preparing, + Downloading, + Installing, + /// An external CLI updater owns its transfer and does not expose byte counters. + Updating, +} + +impl InstallProgress { + fn preparing() -> Self { + Self { + stage: InstallStage::Preparing, + downloaded_bytes: 0, + bytes_per_second: 0, + elapsed_milliseconds: 0, + idle_milliseconds: 0, + } + } +} + +/// Load a built-in, numbers-only observer in npm's Node process. A data URL +/// avoids writing executable helpers or a second download cache to disk. +fn observe_npm(command: &mut Command) { + let original = std::env::var("NODE_OPTIONS").unwrap_or_default(); + let encoded = include_bytes!("npm-progress.mjs") + .iter() + .map(|byte| format!("%{byte:02X}")) + .collect::(); + command + .env("LITHE_NPM_ORIGINAL_NODE_OPTIONS", &original) + .env( + "NODE_OPTIONS", + format!("{original} --import=data:text/javascript,{encoded}"), + ); +} + +fn run_observed_npm( + command: &mut Command, + cancel: &dyn Fn() -> bool, + progress: &dyn Fn(InstallProgress), +) -> Result<(bool, String), environment::RunError> { + observe_npm(command); + environment::run_bounded_status_observed(command, INSTALL_TIMEOUT, cancel, &mut |line| { + if let Some(json) = line.strip_prefix("LITHE_NPM_PROGRESS ") { + if let Ok(event) = serde_json::from_str::(json.trim()) { + progress(event); + } + } + }) +} + +/// Install or replace `agent_id` with its pinned version using the user's npm. +pub fn install( + data_directory: &Path, + agent_id: &str, + cancel: &dyn Fn() -> bool, +) -> Result { + install_with_progress(data_directory, agent_id, cancel, &|_| {}) +} + +/// Installs an adapter and publishes live npm counters on the calling thread. +pub fn install_with_progress( + data_directory: &Path, + agent_id: &str, + cancel: &dyn Fn() -> bool, + progress: &dyn Fn(InstallProgress), +) -> Result { + let _lock = install_lock(); + progress(InstallProgress::preparing()); + let agent = crate::catalog::find(agent_id) + .ok_or_else(|| ManagementError::UnknownAgent(agent_id.into()))?; + let environment = environment::detect(cancel); + // Detection returns an optional snapshot so callers can report missing + // tools, but cancellation must retain its original error semantics. + if cancel() { + return Err(ManagementError::Cancelled); + } + if let Some(issue) = runtime_issues(agent, &environment).into_iter().next() { + return Err(ManagementError::RuntimeMissing(issue)); + } + let npm = environment + .npm + .as_ref() + .map(|npm| npm.path.clone()) + .ok_or_else(|| ManagementError::RuntimeMissing("npm was not found".into()))?; + let agents = data_directory.join("agents"); + let staging = agents.join(format!(".{}.installing", agent.id)); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging).map_err(|error| { + ManagementError::Failed(format!("Could not create {}: {error}", staging.display())) + })?; + let result = run_npm_install_observed(&npm, &staging, agent, cancel, progress).and_then(|()| { + if cancel() { + return Err(ManagementError::Cancelled); + } + if !bin_path(&staging, agent).exists() { + return Err(ManagementError::Failed(format!( + "npm finished, but `{}` was not installed", + agent.bin + ))); + } + let marker = serde_json::to_vec(&Marker { + id: agent.id.into(), + version: agent.version.into(), + }) + .map_err(|error| ManagementError::Failed(error.to_string()))?; + std::fs::write(staging.join(MARKER), marker) + .map_err(|error| ManagementError::Failed(error.to_string()))?; + if cancel() { + return Err(ManagementError::Cancelled); + } + replace_directory(&staging, &agent_directory(data_directory, agent)) + }); + if result.is_err() { + let _ = std::fs::remove_dir_all(&staging); + } + result.map(|()| agent.version.to_owned()) +} + +/// Install a missing CLI with npm or update it using its verified installation owner. +/// +/// Runs only on an explicit click; unknown owners require manual updating. +pub fn install_cli( + agent_id: &str, + cancel: &dyn Fn() -> bool, +) -> Result { + install_cli_with_progress(agent_id, cancel, &|_| {}) +} + +/// Updates through the verified installation owner, publishing only available progress. +pub fn install_cli_with_progress( + agent_id: &str, + cancel: &dyn Fn() -> bool, + progress: &dyn Fn(InstallProgress), +) -> Result { + progress(InstallProgress::preparing()); + let agent = crate::catalog::find(agent_id) + .ok_or_else(|| ManagementError::UnknownAgent(agent_id.into()))?; + let cli = agent.cli.as_ref().ok_or_else(|| { + ManagementError::Failed(format!("{} does not use a separate CLI", agent.name)) + })?; + let environment = environment::detect(cancel); + let plan = cli_update::detect(cli, &environment, cancel)?; + let was_missing = plan.installation.source == CliSource::Missing; + let updater = plan.command.ok_or_else(|| { + ManagementError::Failed(format!( + "{} cannot be updated automatically. {}", + cli.name, plan.installation.update_hint + )) + })?; + let previous = environment::detect_tool(cli.command, cancel); + let result = run_cli_updater( + &updater, + environment::search_path().as_deref(), + cancel, + progress, + ); + finish_cli_update(cli, previous.as_ref(), was_missing, result, || { + // Even a nonzero exit can follow an upstream download retry that succeeded. + // Verify the CLI the Agent actually runs, without interpreting log wording. + environment::detect(cancel); + if cancel() { + return Err(ManagementError::Cancelled); + } + let detected = environment::detect_tool(cli.command, cancel); + if cancel() { + return Err(ManagementError::Cancelled); + } + Ok(detected) + }) +} + +fn finish_cli_update( + cli: &AgentCli, + previous: Option<&DetectedTool>, + was_missing: bool, + result: Result<(bool, String), RunError>, + detect: impl FnOnce() -> Result, ManagementError>, +) -> Result { + let (success, output) = match result { + Ok(completed) => completed, + Err(RunError::Cancelled) => return Err(ManagementError::Cancelled), + Err(RunError::TimedOut) => return Err(ManagementError::TimedOut), + Err(RunError::Start(message)) => { + return Err(ManagementError::Failed(format!( + "Could not run the CLI updater: {message}" + ))) + } + }; + let detected = detect()?; + let verified = verify_updated_cli(cli, detected); + if !success { + // An already usable CLI does not prove a failed update installed anything. + let advanced = verified.as_ref().is_ok_and(|version| { + previous.map_or(was_missing, |old| { + !environment::version_at_least(&old.version, version) + }) + }); + if !advanced { + let verification = verified + .err() + .map(|error| format!("\n{}", error.message())) + .unwrap_or_default(); + return Err(ManagementError::Failed(format!( + "Could not update {} using its installation manager:\n{}{}", + cli.name, + tail(&output), + verification + ))); + } + } + Ok(CliUpdateResult { + cli_version: verified?, + updater_warning: (!success).then(|| tail(&output)), + }) +} + +fn run_cli_updater( + updater: &cli_update::UpdateCommand, + path: Option<&std::ffi::OsStr>, + cancel: &dyn Fn() -> bool, + progress: &dyn Fn(InstallProgress), +) -> Result<(bool, String), RunError> { + let mut command = Command::new(&updater.program); + command.args(&updater.arguments); + if let Some(path) = path { + command.env("PATH", path); + } + if cancel() { + return Err(RunError::Cancelled); + } + if updater.observes_npm { + run_observed_npm(&mut command, cancel, progress) + } else { + progress(InstallProgress { + stage: InstallStage::Updating, + ..InstallProgress::preparing() + }); + if cancel() { + return Err(RunError::Cancelled); + } + command + .env("HOMEBREW_NO_ASK", "1") + .env("HOMEBREW_NO_ANALYTICS", "1"); + environment::run_bounded_status(&mut command, INSTALL_TIMEOUT, cancel) + } +} + +fn verify_updated_cli( + cli: &AgentCli, + detected: Option, +) -> Result { + let tool = detected.ok_or_else(|| ManagementError::Failed(format!( + "The updater finished, but `{}` was not found on PATH. Check the CLI installation and PATH, then check again.", cli.command + )))?; + if !environment::version_at_least(&tool.version, cli.minimum_version) { + return Err(ManagementError::Failed(format!( + "The updater finished, but PATH still selects {} {} at {}. Version {} or later is required; check pinned versions and conflicting installations.", + cli.name, tool.version, tool.path.display(), cli.minimum_version + ))); + } + Ok(tool.version) +} + +/// Remove an installed adapter. Removing a missing adapter succeeds. +pub fn uninstall(data_directory: &Path, agent_id: &str) -> Result<(), ManagementError> { + let _lock = install_lock(); + let agent = crate::catalog::find(agent_id) + .ok_or_else(|| ManagementError::UnknownAgent(agent_id.into()))?; + match std::fs::remove_dir_all(agent_directory(data_directory, agent)) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(ManagementError::Failed( + format!("Could not remove {}: {error}", agent.name), + )), + _ => Ok(()), + } +} + +#[cfg(test)] +fn run_npm_install( + npm: &Path, + prefix: &Path, + agent: &CatalogAgent, + cancel: &dyn Fn() -> bool, +) -> Result<(), ManagementError> { + run_npm_install_observed(npm, prefix, agent, cancel, &|_| {}) +} + +fn run_npm_install_observed( + npm: &Path, + prefix: &Path, + agent: &CatalogAgent, + cancel: &dyn Fn() -> bool, + progress: &dyn Fn(InstallProgress), +) -> Result<(), ManagementError> { + let mut command = Command::new(npm); + command + .arg("install") + .arg("--prefix") + .arg(prefix) + .args(["--no-audit", "--no-fund", "--omit=dev", "--loglevel=error"]) + // Adapters that drive the user's CLI skip their bundled copy, which is + // an optional dependency of several hundred megabytes. + .args(agent.cli.as_ref().map(|_| "--omit=optional")) + .arg(format!("{}@{}", agent.package, agent.version)) + .current_dir(prefix); + if let Some(path) = environment::search_path() { + command.env("PATH", path); + } + match run_observed_npm(&mut command, cancel, progress) { + Ok((true, _)) => Ok(()), + Ok((false, output)) => Err(ManagementError::Failed(format!( + "npm could not install {}:\n{}", + agent.package, + tail(&output) + ))), + Err(RunError::Cancelled) => Err(ManagementError::Cancelled), + Err(RunError::TimedOut) => Err(ManagementError::TimedOut), + Err(RunError::Start(message)) => Err(ManagementError::Failed(format!( + "Could not run npm: {message}" + ))), + } +} + +/// Swap `staging` into `target`, keeping the old install until the swap succeeds. +fn replace_directory(staging: &Path, target: &Path) -> Result<(), ManagementError> { + let previous = target.with_file_name(format!( + ".{}.previous", + target + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("agent") + )); + let _ = std::fs::remove_dir_all(&previous); + let had_previous = target.exists(); + if had_previous { + std::fs::rename(target, &previous).map_err(|error| { + ManagementError::Failed(format!("Could not replace the old install: {error}")) + })?; + } + if let Err(error) = std::fs::rename(staging, target) { + if had_previous { + let _ = std::fs::rename(&previous, target); + } + return Err(ManagementError::Failed(format!( + "Could not finish the install: {error}" + ))); + } + let _ = std::fs::remove_dir_all(&previous); + Ok(()) +} + +fn tail(output: &str) -> String { + let trimmed = output.trim(); + let start = trimmed + .char_indices() + .rev() + .nth(OUTPUT_TAIL - 1) + .map_or(0, |(index, _)| index); + trimmed[start..].to_owned() +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::environment::DetectedTool; + use std::os::unix::fs::PermissionsExt; + + fn temp_directory(name: &str) -> PathBuf { + let directory = + std::env::temp_dir().join(format!("lithe-install-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&directory); + std::fs::create_dir_all(&directory).unwrap(); + directory + } + + /// Fake npm that creates the adapter executable, or fails when asked. + fn fake_npm(directory: &Path, succeed: bool) -> PathBuf { + let npm = directory.join("npm"); + let script = if succeed { + "#!/bin/sh\nprefix=\"$3\"\nmkdir -p \"$prefix/node_modules/.bin\"\nprintf '#!/bin/sh\\n' > \"$prefix/node_modules/.bin/codex-acp\"\nchmod +x \"$prefix/node_modules/.bin/codex-acp\"\n" + } else { + "#!/bin/sh\necho 'npm ERR! 404 Not Found' >&2\nexit 1\n" + }; + std::fs::write(&npm, script).unwrap(); + std::fs::set_permissions(&npm, std::fs::Permissions::from_mode(0o755)).unwrap(); + npm + } + + fn codex() -> &'static CatalogAgent { + crate::catalog::find("codex-acp").unwrap() + } + + fn cli_tool(version: &str) -> DetectedTool { + DetectedTool { + version: version.into(), + path: "/example/bin/codex".into(), + } + } + + #[test] + fn cli_update_nonzero_exit_recovers_only_after_a_usable_version_advance() { + let cli = codex().cli.as_ref().unwrap(); + let old = cli_tool("0.142.5"); + let log = "Download failed; retry completed"; + let result = finish_cli_update(cli, Some(&old), false, Ok((false, log.into())), || { + Ok(Some(cli_tool("0.157.1"))) + }) + .unwrap(); + assert_eq!(result.cli_version, "0.157.1"); + assert_eq!(result.updater_warning.as_deref(), Some(log)); + let fresh = finish_cli_update(cli, None, true, Ok((false, log.into())), || { + Ok(Some(cli_tool("0.157.1"))) + }) + .unwrap(); + assert_eq!(fresh.updater_warning.as_deref(), Some(log)); + } + + #[test] + fn cli_update_nonzero_exit_preserves_failures_without_verified_progress() { + let cli = codex().cli.as_ref().unwrap(); + // Already usable, numerically equivalent, older, absent, and still-too-old + // versions must not turn an updater failure into success. + for (before, after) in [ + ("0.157.1", Some("0.157.1")), + ("0.157.1", Some("v0.157.1")), + ("0.157.1", Some("0.156.1")), + ("0.142.5", Some("0.150.0")), + ("0.142.5", None), + ] { + let old = cli_tool(before); + let result = finish_cli_update( + cli, + Some(&old), + false, + Ok((false, "installer failure".into())), + || Ok(after.map(cli_tool)), + ); + match result { + Err(ManagementError::Failed(message)) => { + assert!(message.contains("installer failure")) + } + other => panic!("unverified update {before} -> {after:?}: {other:?}"), + } + } + } + + #[test] + fn cli_update_unknown_previous_version_cannot_prove_recovery() { + let cli = codex().cli.as_ref().unwrap(); + // A failed version probe of an existing executable is not evidence it was absent. + assert!(finish_cli_update( + cli, + None, + false, + Ok((false, "installer failure".into())), + || Ok(Some(cli_tool("0.157.1"))) + ) + .is_err()); + } + + #[test] + fn cli_update_clean_exit_still_verifies_path_without_a_warning() { + let cli = codex().cli.as_ref().unwrap(); + let old = cli_tool("0.157.1"); + let result = finish_cli_update( + cli, + Some(&old), + false, + Ok((true, "already current".into())), + || Ok(Some(cli_tool("0.157.1"))), + ) + .unwrap(); + assert_eq!(result.updater_warning, None); + for after in [None, Some("0.142.5")] { + assert!( + finish_cli_update(cli, Some(&old), false, Ok((true, String::new())), || Ok( + after.map(cli_tool) + )) + .is_err() + ); + } + } + + #[test] + fn cli_update_cancel_timeout_and_start_failure_never_recover() { + let cli = codex().cli.as_ref().unwrap(); + for (error, expected) in [ + (RunError::Cancelled, ManagementError::Cancelled), + (RunError::TimedOut, ManagementError::TimedOut), + ( + RunError::Start("not executable".into()), + ManagementError::Failed("Could not run the CLI updater: not executable".into()), + ), + ] { + assert_eq!( + finish_cli_update(cli, None, true, Err(error), || panic!( + "must not probe after an incomplete update" + )), + Err(expected) + ); + } + assert_eq!( + finish_cli_update(cli, None, true, Ok((false, String::new())), || Err( + ManagementError::Cancelled + )), + Err(ManagementError::Cancelled) + ); + } + + #[test] + fn cli_update_recovery_keeps_only_the_bounded_log_tail() { + let cli = codex().cli.as_ref().unwrap(); + let result = finish_cli_update( + cli, + None, + true, + Ok((false, "界".repeat(OUTPUT_TAIL + 100))), + || Ok(Some(cli_tool("0.157.1"))), + ) + .unwrap(); + assert_eq!(result.updater_warning.unwrap().chars().count(), OUTPUT_TAIL); + } + + #[test] + fn external_cli_update_runs_owned_arguments_and_checks_the_selected_version() { + let root = temp_directory("external-updater"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let manager = fake_npm(&root, true); + std::fs::write(&manager, "#!/bin/sh\nprintf '%s\\n' \"$@\"\n").unwrap(); + let updater = cli_update::UpdateCommand { + program: manager, + arguments: vec![ + "upgrade".into(), + "--cask".into(), + "codex; literal argument".into(), + ], + observes_npm: false, + }; + let progress = std::cell::RefCell::new(Vec::new()); + let (success, output) = run_cli_updater(&updater, None, &|| false, &|p| { + progress.borrow_mut().push(p) + }) + .unwrap(); + assert!(success); + assert_eq!( + output.lines().collect::>(), + ["upgrade", "--cask", "codex; literal argument"] + ); + assert_eq!(progress.borrow()[0].stage, InstallStage::Updating); + assert_eq!(progress.borrow()[0].downloaded_bytes, 0); + let cli = codex().cli.as_ref().unwrap(); + let detected = |version: &str| { + Some(DetectedTool { + version: version.into(), + path: root.join("codex"), + }) + }; + assert!( + verify_updated_cli(cli, detected("0.142.5")).is_err(), + "an old CLI earlier in PATH cannot become success" + ); + assert_eq!( + verify_updated_cli(cli, detected("0.156.1")).unwrap(), + "0.156.1" + ); + assert!(verify_updated_cli(cli, None).is_err()); + let cancelled = std::cell::Cell::new(false); + let result = run_cli_updater(&updater, None, &|| cancelled.get(), &|_| { + cancelled.set(true) + }); + assert_eq!(result, Err(RunError::Cancelled)); + } + + #[test] + fn live_npm_progress_arrives_before_cancel_and_preserves_cancellation() { + use std::sync::atomic::{AtomicBool, Ordering}; + // A fake npm reports bytes, then stays alive until the observer cancels. + // No network, installed npm, or sleep is needed to control this sequence. + let root = temp_directory("progress-cancel"); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(root.clone()); + let npm = fake_npm(&root, true); + std::fs::write(&npm, r#"#!/bin/sh +printf '%s\n' 'LITHE_NPM_PROGRESS {"stage":"downloading","downloadedBytes":1234,"bytesPerSecond":123,"elapsedMilliseconds":1000,"idleMilliseconds":0}' +while :; do :; done +"#).unwrap(); + let cancelled = AtomicBool::new(false); + let received = std::cell::RefCell::new(Vec::new()); + // If the observer regresses, stop the fake child locally rather than + // leaving it running until the production install timeout. + let deadline = std::time::Instant::now() + Duration::from_secs(3); + let result = run_npm_install_observed( + &npm, + &root, + codex(), + &|| cancelled.load(Ordering::SeqCst) || std::time::Instant::now() >= deadline, + &|event| { + received.borrow_mut().push(event); + cancelled.store(true, Ordering::SeqCst); + }, + ); + assert!(matches!(result, Err(ManagementError::Cancelled))); + assert_eq!(received.borrow().len(), 1); + assert_eq!(received.borrow()[0].downloaded_bytes, 1234); + } + + #[test] + fn successful_install_replaces_the_previous_version_atomically() { + let data = temp_directory("ok"); + let npm = fake_npm(&data, true); + let old = agent_directory(&data, codex()); + std::fs::create_dir_all(&old).unwrap(); + std::fs::write(old.join("stale"), "old").unwrap(); + + let staging = data.join("agents/.codex-acp.installing"); + std::fs::create_dir_all(&staging).unwrap(); + run_npm_install(&npm, &staging, codex(), &|| false).unwrap(); + std::fs::write( + staging.join(MARKER), + r#"{"id":"codex-acp","version":"1.13.1"}"#, + ) + .unwrap(); + replace_directory(&staging, &old).unwrap(); + + assert_eq!(installed_version(&data, codex()).as_deref(), Some("1.13.1")); + assert!(!old.join("stale").exists()); + assert!(!staging.exists()); + assert!(installed_command(&data, codex()).exists()); + uninstall(&data, "codex-acp").unwrap(); + assert_eq!(installed_version(&data, codex()), None); + uninstall(&data, "codex-acp").unwrap(); + let _ = std::fs::remove_dir_all(&data); + } + + #[test] + fn npm_failure_reports_its_output_and_keeps_the_old_install() { + let data = temp_directory("fail"); + let npm = fake_npm(&data, false); + let staging = data.join("staging"); + std::fs::create_dir_all(&staging).unwrap(); + match run_npm_install(&npm, &staging, codex(), &|| false) { + Err(ManagementError::Failed(message)) => { + assert!(message.contains("404 Not Found"), "{message}") + } + other => panic!("expected failure, got {other:?}"), + } + assert_eq!( + run_npm_install(&npm, &staging, codex(), &|| true), + Err(ManagementError::Cancelled) + ); + let _ = std::fs::remove_dir_all(&data); + } + + #[test] + fn cancellation_during_runtime_detection_is_not_reported_as_missing_node() { + let data = temp_directory("detect-cancel"); + let result = install_with_progress(&data, "codex-acp", &|| true, &|_| {}); + assert_eq!(result, Err(ManagementError::Cancelled)); + let _ = std::fs::remove_dir_all(&data); + } + + #[test] + fn missing_or_old_node_is_reported_per_agent() { + let tool = |version: &str| { + Some(DetectedTool { + version: version.into(), + path: "/bin/tool".into(), + }) + }; + let environment = |node: Option| RuntimeEnvironment { + node, + npm: tool("10.0.0"), + used_login_shell: true, + }; + assert!(runtime_issues(codex(), &environment(tool("22.1.0"))).is_empty()); + let claude = crate::catalog::find("claude-acp").unwrap(); + let old = runtime_issues(claude, &environment(tool("20.11.0"))); + assert_eq!(old.len(), 1); + assert!(old[0].contains("Node.js 22"), "{}", old[0]); + assert!(runtime_issues(codex(), &environment(None))[0].contains("was not found")); + assert!(uninstall(Path::new("/tmp"), "unknown").is_err()); + } + + #[test] + fn output_tail_keeps_the_end() { + let long = format!("{}END", "x".repeat(OUTPUT_TAIL * 2)); + let tail = tail(&long); + assert!(tail.ends_with("END")); + assert!(tail.chars().count() <= OUTPUT_TAIL + 1); + } +} diff --git a/rust/lithe-agent-host/src/lib.rs b/rust/lithe-agent-host/src/lib.rs new file mode 100644 index 000000000..f9a1fca42 --- /dev/null +++ b/rust/lithe-agent-host/src/lib.rs @@ -0,0 +1,1207 @@ +//! Shared ACP client connection and bounded agent subprocess ownership. +//! +//! One [`AgentHandle`] owns one agent process and one ACP connection for a +//! workspace. A connection carries many conversation sessions; the agent owns +//! their history (`session/list`, `session/load`). Product UI, settings, and +//! credential storage stay with the platform applications. +//! See `.agents/notes/implemented/architecture/2026-09-25-shared-acp-agent-conversation.md`. + +pub mod catalog; +pub mod cli_update; +pub mod environment; +pub mod install; +mod prompt; +mod session_defaults; + +pub use catalog::{ModelDelivery, ProviderProtocol}; +pub use prompt::PromptFile; + +use std::collections::{HashMap, VecDeque}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::Duration; + +use agent_client_protocol::schema::v1::{ + AuthCapabilities, AuthenticateRequest, CancelNotification, ClientCapabilities, + InitializeRequest, ListSessionsRequest, LoadSessionRequest, PromptRequest, + RequestPermissionOutcome, RequestPermissionRequest, RequestPermissionResponse, + SelectedPermissionOutcome, SessionConfigOption, SessionNotification, + SetSessionConfigOptionRequest, +}; +use agent_client_protocol::schema::ProtocolVersion; +use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncReadExt; +use tokio::sync::{mpsc as async_mpsc, oneshot}; +use tokio::task::JoinSet; +use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; + +const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(20); +const SESSION_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const LOAD_SESSION_TIMEOUT: Duration = Duration::from_secs(60); +const STOP_TIMEOUT: Duration = Duration::from_secs(3); +const PERMISSION_TIMEOUT: Duration = Duration::from_secs(300); +const CANCEL_TIMEOUT: Duration = Duration::from_secs(10); +/// Upper bound on `session/list` pages so a misbehaving cursor cannot loop forever. +const MAX_SESSION_LIST_PAGES: usize = 50; +/// Bytes of agent stderr kept for failure reports; older output is discarded. +const STDERR_TAIL_BYTES: usize = 16 * 1024; +const STDERR_TAIL_LINES: usize = 20; +/// Auth method id and `_meta` key of the ACP custom model gateway extension. +/// Lithe authenticates only with user-supplied API keys through this method and +/// never triggers an agent's own account login. +const GATEWAY_AUTH_METHOD: &str = "gateway"; + +/// Launch configuration supplied by the owning desktop product. +/// +/// A catalog agent (`agentId`) is started from its Lithe-managed install and +/// receives the key the way its adapter requires. Without `agentId`, `command` +/// runs a user-provided agent that must support gateway sign-in. +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentLaunch { + #[serde(default)] + pub agent_id: Option, + #[serde(default)] + pub command: Option, + #[serde(default)] + pub args: Vec, + /// Absolute workspace root; sessions are created and listed for this directory. + pub cwd: PathBuf, + /// Directory holding Lithe-managed adapter installs; required with `agentId`. + #[serde(default)] + pub data_directory: Option, + pub provider: ProviderCredentials, +} + +/// User-supplied AI provider endpoint and API key. +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProviderCredentials { + pub protocol: ProviderProtocol, + /// Endpoint as configured, with or without the protocol's path suffix. + pub base_url: String, + pub api_key: String, + #[serde(default)] + pub name: Option, + /// Model to request; empty or absent keeps the agent's default. + #[serde(default)] + pub model: Option, + /// Allow a plain `http` endpoint, e.g. a local gateway the user opted into. + #[serde(default)] + pub allow_insecure_http: bool, +} + +impl ProviderCredentials { + /// Responses base URL accepted by gateway sign-in, e.g. `https://host/v1`. + fn responses_base_url(&self) -> Result { + let trimmed = self.base_url.trim().trim_end_matches('/'); + self.checked(trimmed.strip_suffix("/responses").unwrap_or(trimmed)) + } + + /// Anthropic base URL without `/v1` or `/v1/messages`, as the SDK expects. + fn anthropic_base_url(&self) -> Result { + let trimmed = self.base_url.trim().trim_end_matches('/'); + let base = trimmed.strip_suffix("/messages").unwrap_or(trimmed); + self.checked(base.strip_suffix("/v1").unwrap_or(base)) + } + + fn checked(&self, base: &str) -> Result { + let secure = base.starts_with("https://"); + let insecure = base.starts_with("http://"); + if !(secure || insecure && self.allow_insecure_http) { + return Err("The API endpoint must use https".into()); + } + let host = base + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or_default(); + if host.is_empty() || host.starts_with('/') || base.contains(['?', '#', ' ']) { + return Err("The API endpoint is not a valid URL".into()); + } + Ok(base.to_owned()) + } +} + +impl std::fmt::Debug for ProviderCredentials { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProviderCredentials") + .field("protocol", &self.protocol) + .field("base_url", &self.base_url) + .field("api_key", &"") + .field("name", &self.name) + .field("model", &self.model) + .field("allow_insecure_http", &self.allow_insecure_http) + .finish() + } +} + +/// Gateway sign-in sent in ACP `authenticate`. +#[derive(Clone)] +struct GatewaySignIn { + base_url: String, + /// Authentication headers in the dialect of the provider's protocol. + headers: Vec<(String, String)>, + provider_name: Option, +} + +/// A validated launch: what to run and how the key reaches the agent. +struct ResolvedLaunch { + command: PathBuf, + args: Vec, + cwd: PathBuf, + /// Extra environment, used only by adapters that accept no other key input. + env: Vec<(String, String)>, + gateway: Option, + /// Key to redact from diagnostics. + secret: String, +} + +#[cfg(test)] +fn resolve(launch: AgentLaunch) -> Result { + resolve_with_cancel(launch, &|| false) +} + +/// Resolve a launch while allowing runtime and CLI detection to observe stop. +fn resolve_with_cancel( + launch: AgentLaunch, + cancel: &dyn Fn() -> bool, +) -> Result { + let resolved = resolve_with(launch, &|command| environment::detect_tool(command, cancel)); + if cancel() { + return Err("Agent launch was cancelled".into()); + } + resolved +} + +/// [`resolve`] with an injectable lookup for the user's agent CLI. +fn resolve_with( + launch: AgentLaunch, + find_cli: &dyn Fn(&str) -> Option, +) -> Result { + let provider = launch.provider; + if provider.api_key.trim().is_empty() { + return Err("An API key is required".into()); + } + if !launch.cwd.is_absolute() { + return Err("The workspace path must be absolute".into()); + } + // Every adapter signs in through the ACP `gateway` method; the key travels + // over stdio in the header its protocol expects. + let gateway = |provider: &ProviderCredentials| -> Result { + let (base_url, header) = match provider.protocol { + ProviderProtocol::AnthropicMessages => ( + provider.anthropic_base_url()?, + ("x-api-key".to_owned(), provider.api_key.clone()), + ), + ProviderProtocol::Responses | ProviderProtocol::ChatCompletions => ( + provider.responses_base_url()?, + ( + "Authorization".to_owned(), + format!("Bearer {}", provider.api_key), + ), + ), + }; + Ok(GatewaySignIn { + base_url, + headers: vec![header], + provider_name: provider.name.clone(), + }) + }; + let Some(agent_id) = launch.agent_id else { + let command = launch.command.unwrap_or_default(); + if command.trim().is_empty() { + return Err("Set the ACP Agent executable before starting a conversation".into()); + } + if provider.protocol != ProviderProtocol::Responses { + return Err("A custom Agent needs a provider that uses the Responses API".into()); + } + return Ok(ResolvedLaunch { + command: PathBuf::from(command), + args: launch.args, + cwd: launch.cwd, + env: Vec::new(), + gateway: Some(gateway(&provider)?), + secret: provider.api_key, + }); + }; + let agent = catalog::find(&agent_id).ok_or_else(|| format!("Unknown agent `{agent_id}`"))?; + if provider.protocol != agent.protocol { + return Err(format!( + "{} needs an AI provider that uses the {} protocol", + agent.name, + match agent.protocol { + ProviderProtocol::Responses => "Responses API", + ProviderProtocol::ChatCompletions => "Chat Completions", + ProviderProtocol::AnthropicMessages => "Anthropic Messages", + } + )); + } + let data_directory = launch + .data_directory + .ok_or("The Agent install directory is not configured")?; + if install::installed_version(&data_directory, agent).is_none() { + return Err(format!( + "{} is not installed. Install it in Settings › Agents.", + agent.name + )); + } + let gateway = Some(gateway(&provider)?); + let mut env = Vec::new(); + if let Some(cli) = &agent.cli { + let detected = find_cli(cli.command); + if let Some(issue) = install::cli_issue(cli, detected.as_ref()) { + return Err(issue); + } + if let Some(tool) = detected { + env.push(( + cli.path_env.to_owned(), + tool.path.to_string_lossy().into_owned(), + )); + } + } + if let Some(model) = provider + .model + .as_deref() + .map(str::trim) + .filter(|model| !model.is_empty()) + { + env.push(match agent.model_delivery { + ModelDelivery::CodexConfig => ( + "CODEX_CONFIG".to_owned(), + serde_json::json!({ "model": model }).to_string(), + ), + ModelDelivery::AnthropicEnvironment => ("ANTHROPIC_MODEL".to_owned(), model.to_owned()), + }); + } + Ok(ResolvedLaunch { + command: install::installed_command(&data_directory, agent), + args: launch.args, + cwd: launch.cwd, + env, + gateway, + secret: provider.api_key, + }) +} + +/// Commands accepted by an open connection, as UTF-8 JSON from the platform. +/// +/// `token` values are caller-chosen and echoed on the matching result event so +/// the UI can correlate concurrent requests. +#[derive(Debug, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum AgentCommand { + NewSession { + token: String, + }, + LoadSession { + token: String, + session_id: String, + }, + ListSessions { + token: String, + }, + Prompt { + session_id: String, + text: String, + #[serde(default)] + files: Vec, + }, + Cancel { + session_id: String, + }, + SetConfigOption { + token: String, + session_id: String, + config_id: String, + value: String, + }, + Permission { + request_id: String, + option_id: Option, + }, +} + +/// One entry of the agent-owned conversation history for the workspace. +#[derive(Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentSessionSummary { + pub session_id: String, + pub title: Option, + /// ISO 8601 timestamp reported by the agent, if any. + pub updated_at: Option, +} + +/// Events emitted from the connection worker thread. +/// +/// Field names are fixed by `shared/fixtures/agent/acp-events-v1.json`. +#[derive(Debug, Serialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum AgentEvent { + /// Initialization and API-key authentication succeeded. + Ready { + agent_name: Option, + agent_version: Option, + can_load_sessions: bool, + can_list_sessions: bool, + }, + SessionCreated { + token: String, + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + config_options: Option>, + }, + /// `session/load` returned. History replay arrives as `update` events and + /// may continue after this event. + SessionLoaded { + token: String, + session_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + config_options: Option>, + }, + SessionConfigured { + token: String, + session_id: String, + config_options: Vec, + }, + TurnCancelling { + session_id: String, + }, + Sessions { + token: String, + sessions: Vec, + }, + /// A raw ACP `SessionUpdate`, including replayed history. + Update { + session_id: String, + update: serde_json::Value, + }, + Permission { + session_id: String, + request_id: String, + request: serde_json::Value, + }, + /// The agent acknowledged the prompt's completion, including cancellation. + TurnFinished { + session_id: String, + stop_reason: String, + }, + /// A command failed without ending the connection. + RequestFailed { + token: Option, + session_id: Option, + message: String, + }, + /// The connection ended. `message` is absent only after a requested stop. + Stopped { + message: Option, + }, +} + +type Emit = Arc; + +struct PendingPermission { + session_id: String, + reply: oneshot::Sender>, +} + +/// Permission requests awaiting a user decision, keyed by Lithe request id. +type PendingPermissions = Arc>>; + +/// Running turn generation per session. A session is absent while idle, so a +/// response whose generation no longer matches belongs to a cancelled turn. +type RunningTurns = Arc>>; + +struct RunningTurn { + generation: u64, + cancelling: bool, +} + +enum Control { + Command(AgentCommand), + Stop, +} + +/// One agent process and ACP connection. Dropping it stops the process tree. +pub struct AgentHandle { + controls: async_mpsc::UnboundedSender, + permissions: PendingPermissions, + child_pid: Arc, + stop_requested: Arc, + finished: mpsc::Receiver<()>, + worker: Option>, +} + +impl AgentHandle { + /// Start the worker thread, spawn the agent, and authenticate. + /// + /// Invalid settings and launch or protocol failures are reported as a + /// `stopped` event with a user-facing message; no process is started for + /// invalid settings. Only a failure to create the worker thread is returned. + pub fn open(launch: AgentLaunch, emit: Emit) -> Result { + let (controls, receiver) = async_mpsc::unbounded_channel(); + let (finished_tx, finished) = mpsc::channel(); + let permissions: PendingPermissions = Arc::new(Mutex::new(HashMap::new())); + let child_pid = Arc::new(AtomicU32::new(0)); + let stop_requested = Arc::new(AtomicBool::new(false)); + let pending = permissions.clone(); + let pid = child_pid.clone(); + let stop = stop_requested.clone(); + let worker = std::thread::Builder::new() + .name("lithe-acp-connection".into()) + .spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + let message = match runtime { + Ok(runtime) => runtime + .block_on(run_agent( + launch, + receiver, + pending, + pid, + stop, + emit.clone(), + )) + .err(), + Err(error) => Some(error.to_string()), + }; + emit(AgentEvent::Stopped { message }); + let _ = finished_tx.send(()); + }) + .map_err(|error| error.to_string())?; + Ok(Self { + controls, + permissions, + child_pid, + stop_requested, + finished, + worker: Some(worker), + }) + } + + /// Queue a command. Permission answers and cancel-time permission + /// rejection take effect immediately on the calling thread. + pub fn send(&self, command: AgentCommand) -> Result<(), String> { + match command { + AgentCommand::Permission { + request_id, + option_id, + } => answer_permission(&self.permissions, &request_id, option_id), + command => { + if let AgentCommand::Cancel { session_id } = &command { + // ACP requires pending permission requests of a cancelled + // turn to be answered `cancelled`; do it before queuing. + reject_pending_permissions(&self.permissions, Some(session_id)); + } + self.controls + .send(Control::Command(command)) + .map_err(|_| "Agent connection has stopped".into()) + } + } + } + + /// Stop the connection and bound cleanup of the subprocess tree. + pub fn close(mut self) { + self.stop(); + } + + fn stop(&mut self) { + if self.worker.is_none() { + return; + } + reject_pending_permissions(&self.permissions, None); + self.stop_requested.store(true, Ordering::Release); + let _ = self.controls.send(Control::Stop); + if self.finished.recv_timeout(STOP_TIMEOUT).is_err() { + let pid = self.child_pid.load(Ordering::SeqCst); + if pid != 0 { + force_kill_tree(pid); + } + let _ = self.finished.recv_timeout(STOP_TIMEOUT); + } + if let Some(worker) = self.worker.take() { + if worker.is_finished() { + let _ = worker.join(); + } + } + } +} + +impl Drop for AgentHandle { + fn drop(&mut self) { + self.stop(); + } +} + +/// Deliver a user decision; `None` rejects. Fails once the request is gone. +fn answer_permission( + permissions: &PendingPermissions, + request_id: &str, + option_id: Option, +) -> Result<(), String> { + let pending = permissions + .lock() + .ok() + .and_then(|mut pending| pending.remove(request_id)); + match pending { + Some(pending) => { + let _ = pending.reply.send(option_id); + Ok(()) + } + None => Err("The permission request is no longer pending".into()), + } +} + +fn reject_pending_permissions(permissions: &PendingPermissions, session_id: Option<&str>) { + if let Ok(mut pending) = permissions.lock() { + let ids: Vec = pending + .iter() + .filter(|(_, entry)| session_id.is_none_or(|id| entry.session_id == id)) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + if let Some(entry) = pending.remove(&id) { + let _ = entry.reply.send(None); + } + } + } +} + +/// Bounded buffer of the most recent agent stderr bytes. +#[derive(Default)] +struct StderrTail(VecDeque); + +impl StderrTail { + fn push(&mut self, bytes: &[u8]) { + self.0.extend(bytes); + let excess = self.0.len().saturating_sub(STDERR_TAIL_BYTES); + self.0.drain(..excess); + } + + /// Last lines of stderr with the API key removed, or `None` when empty. + fn summary(&self, secret: &str) -> Option { + let (front, back) = self.0.as_slices(); + let text = String::from_utf8_lossy(&[front, back].concat()).into_owned(); + let lines: Vec<&str> = text + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + let start = lines.len().saturating_sub(STDERR_TAIL_LINES); + let tail = lines[start..].join("\n"); + (!tail.is_empty()).then(|| redact(&tail, secret)) + } +} + +fn redact(text: &str, secret: &str) -> String { + if secret.is_empty() { + text.to_owned() + } else { + text.replace(secret, "") + } +} + +/// Child `PATH`: the executable's directory, then `base`. Package managers +/// such as npm install an agent script next to the `node` it runs with, and +/// `base` carries the login shell's `PATH`, which GUI apps do not inherit. +fn child_path(command: &Path, base: Option) -> Option { + let mut paths: Vec = command + .parent() + .filter(|dir| dir.is_absolute()) + .map(Path::to_path_buf) + .into_iter() + .collect(); + if let Some(base) = base { + paths.extend(std::env::split_paths(&base)); + } + (!paths.is_empty()) + .then(|| std::env::join_paths(paths).ok()) + .flatten() +} + +async fn run_agent( + launch: AgentLaunch, + controls: async_mpsc::UnboundedReceiver, + permissions: PendingPermissions, + child_pid: Arc, + stop_requested: Arc, + emit: Emit, +) -> Result<(), String> { + let cancelled = || stop_requested.load(Ordering::Acquire); + let launch = resolve_with_cancel(launch, &cancelled)?; + if cancelled() { + return Err("Agent launch was cancelled".into()); + } + let mut command = std::process::Command::new(&launch.command); + command + .args(&launch.args) + .current_dir(&launch.cwd) + .envs(launch.env.iter().map(|(key, value)| (key, value))); + let search_path = + environment::search_path_with_cancel(&cancelled).or_else(|| std::env::var_os("PATH")); + if cancelled() { + return Err("Agent launch was cancelled".into()); + } + if let Some(path) = child_path(&launch.command, search_path) { + command.env("PATH", path); + } + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + command.process_group(0); + } + let mut command = tokio::process::Command::from(command); + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command.kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| format!("Could not start the Agent: {error}"))?; + child_pid.store(child.id().unwrap_or(0), Ordering::SeqCst); + if cancelled() { + terminate_tree(&mut child).await; + child_pid.store(0, Ordering::SeqCst); + return Err("Agent launch was cancelled".into()); + } + let stdin = child.stdin.take().ok_or("Agent stdin is unavailable")?; + let stdout = child.stdout.take().ok_or("Agent stdout is unavailable")?; + let mut stderr = child.stderr.take().ok_or("Agent stderr is unavailable")?; + let tail = Arc::new(Mutex::new(StderrTail::default())); + let stderr_tail = tail.clone(); + let stderr_task = tokio::spawn(async move { + let mut buffer = [0u8; 4096]; + while let Ok(read) = stderr.read(&mut buffer).await { + if read == 0 { + break; + } + if let Ok(mut tail) = stderr_tail.lock() { + tail.push(&buffer[..read]); + } + } + }); + let secret = launch.secret.clone(); + let transport = ByteStreams::new(stdin.compat_write(), stdout.compat()); + let result = run_connection( + transport, + launch.cwd, + launch.gateway, + controls, + permissions, + emit, + ) + .await; + // The connection may finish before the child exits. Never leave its process + // tree running, including wrapper commands which launch another process. + terminate_tree(&mut child).await; + child_pid.store(0, Ordering::SeqCst); + let _ = tokio::time::timeout(STOP_TIMEOUT, stderr_task).await; + result.map_err(|message| { + let message = redact(&message, &secret); + match tail.lock().ok().and_then(|tail| tail.summary(&secret)) { + Some(stderr) => format!("{message}\n\nAgent output:\n{stderr}"), + None => message, + } + }) +} + +/// Ask the whole agent tree to exit, then force-kill whatever is left. +/// +/// Agents such as codex-acp run a separate app-server that can outlive the +/// wrapper by seconds after SIGTERM. Once the direct child exits, descendants +/// are re-parented and no longer reachable from its PID, so the tree is +/// recorded before signalling and survivors are killed by recorded PID. +async fn terminate_tree(child: &mut tokio::process::Child) { + let Some(pid) = child.id() else { + return; + }; + let tree: Vec = kill_tree::blocking::kill_tree(pid) + .map(|outputs| { + outputs + .into_iter() + .filter_map(|output| match output { + kill_tree::Output::Killed { process_id, .. } => Some(process_id), + kill_tree::Output::MaybeAlreadyTerminated { .. } => None, + }) + .collect() + }) + .unwrap_or_default(); + let _ = tokio::time::timeout(STOP_TIMEOUT, child.wait()).await; + for process_id in tree { + force_kill_tree(process_id); + } + let _ = tokio::time::timeout(STOP_TIMEOUT, child.wait()).await; +} + +/// SIGKILL a process and its descendants; a process that already exited is ignored. +/// +/// Children are started as process-group leaders, so on Unix the whole group is +/// killed as well: a descendant forked after the tree snapshot would otherwise +/// survive and keep the output pipes open. +fn force_kill_tree(process_id: u32) { + let config = kill_tree::Config { + signal: "SIGKILL".into(), + include_target: true, + }; + let _ = kill_tree::blocking::kill_tree_with_config(process_id, &config); + #[cfg(unix)] + if let Ok(group) = libc::pid_t::try_from(process_id) { + if group > 1 { + // SAFETY: `kill` only sends a signal; a negative id addresses the + // group this process created with `process_group(0)`. + unsafe { + libc::kill(-group, libc::SIGKILL); + } + } + } +} + +async fn run_connection( + transport: ByteStreams, + cwd: PathBuf, + gateway: Option, + mut controls: async_mpsc::UnboundedReceiver, + permissions: PendingPermissions, + emit: Emit, +) -> Result<(), String> +where + OB: futures::io::AsyncWrite + Send + 'static, + IB: futures::io::AsyncRead + Send + 'static, +{ + let turns: RunningTurns = Arc::new(Mutex::new(HashMap::new())); + let updates = emit.clone(); + let requests = emit.clone(); + let permission_turns = turns.clone(); + let cancel_permissions = permissions.clone(); + let stop_requested = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stopped = stop_requested.clone(); + let result = Client + .builder() + .on_receive_notification( + async move |notification: SessionNotification, _| { + if let Ok(update) = serde_json::to_value(notification.update) { + updates(AgentEvent::Update { + session_id: notification.session_id.0.to_string(), + update, + }); + } + Ok(()) + }, + agent_client_protocol::on_receive_notification!(), + ) + .on_receive_request( + async move |request: RequestPermissionRequest, responder, _| { + let session_id = request.session_id.0.to_string(); + let request_id = uuid::Uuid::new_v4().to_string(); + let (reply, receiver) = oneshot::channel(); + // Register under the permission lock only while the turn is + // still running, so a concurrent cancel cannot miss it. + let registered = match permissions.lock() { + Ok(mut pending) => { + let running = permission_turns + .lock() + .is_ok_and(|turns| turns.get(&session_id).is_some_and(|turn| !turn.cancelling)); + if running { + pending.insert( + request_id.clone(), + PendingPermission { + session_id: session_id.clone(), + reply, + }, + ); + } + running + } + Err(_) => false, + }; + if !registered { + return responder.respond(RequestPermissionResponse::new( + RequestPermissionOutcome::Cancelled, + )); + } + if let Ok(value) = serde_json::to_value(&request) { + requests(AgentEvent::Permission { + session_id, + request_id: request_id.clone(), + request: value, + }); + } + let selected = tokio::time::timeout(PERMISSION_TIMEOUT, receiver) + .await + .ok() + .and_then(Result::ok) + .flatten(); + if let Ok(mut pending) = permissions.lock() { + pending.remove(&request_id); + } + let outcome = match selected.filter(|id| { + request + .options + .iter() + .any(|option| option.option_id.0.as_ref() == id) + }) { + Some(id) => { + RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)) + } + None => RequestPermissionOutcome::Cancelled, + }; + responder.respond(RequestPermissionResponse::new(outcome)) + }, + agent_client_protocol::on_receive_request!(), + ) + // Without this, an agent that exits leaves the command loop waiting + // forever and the UI never learns the connection is gone. + .on_close(async |_| Err(internal("The Agent exited"))) + .connect_with(transport, |connection: ConnectionTo| async move { + // Negotiate only the upstream recommendation extension we consume; + // this namespace does not change product branding or permissions. + let capabilities = ClientCapabilities::new() + .auth(AuthCapabilities::new().meta(serde_json::Map::from_iter([( + GATEWAY_AUTH_METHOD.to_owned(), + serde_json::Value::Bool(true), + )]))) + .meta(serde_json::Map::from_iter([( + "jetbrains".to_owned(), + serde_json::json!({ "air": { "version": 1, "capabilities": ["recommendedValue"] } }), + )])); + let initialized = tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connection + .send_request( + InitializeRequest::new(ProtocolVersion::V1) + .client_capabilities(capabilities), + ) + .block_task(), + ) + .await + .map_err(|_| internal("The Agent did not finish initialization in time"))??; + // Only agents that take the key over ACP sign in here; the others + // received it in their environment. Account logins are never used. + if let Some(gateway) = &gateway { + if !initialized + .auth_methods + .iter() + .any(|method| method.id().0.as_ref() == GATEWAY_AUTH_METHOD) + { + return Err(internal( + "This Agent does not support signing in with a custom API key", + )); + } + tokio::time::timeout( + HANDSHAKE_TIMEOUT, + connection + .send_request(gateway_authentication(gateway)) + .block_task(), + ) + .await + .map_err(|_| internal("The Agent did not finish API key sign-in in time"))??; + } + let agent = initialized.agent_info.as_ref(); + emit(AgentEvent::Ready { + agent_name: agent.map(|info| info.name.clone()), + agent_version: agent.map(|info| info.version.clone()), + can_load_sessions: initialized.agent_capabilities.load_session, + can_list_sessions: initialized + .agent_capabilities + .session_capabilities + .list + .is_some(), + }); + + let generations = AtomicU64::new(0); + let mut tasks = JoinSet::new(); + let (cancel_deadline_tx, mut cancel_deadlines) = async_mpsc::unbounded_channel(); + loop { + let control = tokio::select! { + control = controls.recv() => match control { + Some(control) => control, + None => break, + }, + deadline = cancel_deadlines.recv() => { + let Some((session_id, generation)) = deadline else { continue }; + if turns.lock().is_ok_and(|turns| turns.get(&session_id).is_some_and( + |turn| turn.generation == generation && turn.cancelling + )) { + return Err(internal("The Agent did not acknowledge Stop. Its process has been stopped to prevent overlapping turns. Reconnect to reload the conversation.")); + } + continue; + } + }; + while tasks.try_join_next().is_some() {} + let command = match control { + Control::Stop => { + stopped.store(true, Ordering::SeqCst); + break; + } + Control::Command(command) => command, + }; + match command { + AgentCommand::NewSession { token } => { + let connection = connection.clone(); + let emit = emit.clone(); + let cwd = cwd.clone(); + tasks.spawn(async move { + let result = request_with_timeout( + SESSION_REQUEST_TIMEOUT, + session_defaults::new_session(&connection, cwd), + ) + .await; + emit(match result { + Ok(response) => AgentEvent::SessionCreated { + token, + session_id: response.session_id.0.to_string(), + config_options: response.config_options, + }, + Err(message) => failed(Some(token), None, message), + }); + }); + } + AgentCommand::LoadSession { token, session_id } => { + let connection = connection.clone(); + let emit = emit.clone(); + let cwd = cwd.clone(); + tasks.spawn(async move { + let result = request_with_timeout( + LOAD_SESSION_TIMEOUT, + connection + .send_request(LoadSessionRequest::new(session_id.clone(), cwd)) + .block_task(), + ) + .await; + emit(match result { + Ok(response) => AgentEvent::SessionLoaded { token, session_id, config_options: response.config_options }, + Err(message) => failed(Some(token), Some(session_id), message), + }); + }); + } + AgentCommand::ListSessions { token } => { + let connection = connection.clone(); + let emit = emit.clone(); + let cwd = cwd.clone(); + tasks.spawn(async move { + emit(match list_sessions(&connection, cwd).await { + Ok(sessions) => AgentEvent::Sessions { token, sessions }, + Err(message) => failed(Some(token), None, message), + }); + }); + } + AgentCommand::SetConfigOption { token, session_id, config_id, value } => { + if turns.lock().is_ok_and(|turns| turns.contains_key(&session_id)) { + emit(failed(Some(token), Some(session_id), "Wait for the current turn before changing configuration".into())); + continue; + } + let connection = connection.clone(); + let emit = emit.clone(); + tasks.spawn(async move { + let result = request_with_timeout( + SESSION_REQUEST_TIMEOUT, + connection.send_request(SetSessionConfigOptionRequest::new(session_id.clone(), config_id, value.as_str())).block_task(), + ).await; + emit(match result { + Ok(response) => AgentEvent::SessionConfigured { token, session_id, config_options: response.config_options }, + Err(message) => failed(Some(token), Some(session_id), message), + }); + }); + } + AgentCommand::Prompt { session_id, text, files } => { + let content = match prompt::content(text, files) { + Ok(content) => content, + Err(message) => { + emit(failed(None, Some(session_id), message)); + continue; + } + }; + let generation = generations.fetch_add(1, Ordering::SeqCst) + 1; + let busy = match turns.lock() { + Ok(mut turns) if !turns.contains_key(&session_id) => { + turns.insert(session_id.clone(), RunningTurn { generation, cancelling: false }); + false + } + _ => true, + }; + if busy { + emit(failed( + None, + Some(session_id), + "The Agent is still responding in this conversation".into(), + )); + continue; + } + let request = PromptRequest::new( + session_id.clone(), + content, + ); + let response = connection.send_request(request).block_task(); + let emit = emit.clone(); + let turns = turns.clone(); + let prompt_permissions = cancel_permissions.clone(); + tasks.spawn(async move { + let result = response.await; + let current = turns.lock().is_ok_and(|mut turns| { + let current = turns.get(&session_id).is_some_and(|turn| turn.generation == generation); + if current { + turns.remove(&session_id); + } + current + }); + if !current { + return; + } + reject_pending_permissions(&prompt_permissions, Some(&session_id)); + emit(match result { + Ok(response) => AgentEvent::TurnFinished { + session_id, + stop_reason: stop_reason_name(&response.stop_reason), + }, + Err(error) => failed(None, Some(session_id), error.to_string()), + }); + }); + } + AgentCommand::Cancel { session_id } => { + let generation = turns + .lock() + .ok() + .and_then(|mut turns| { + let turn = turns.get_mut(&session_id)?; + if turn.cancelling { return None; } + turn.cancelling = true; + Some(turn.generation) + }); + // A request registered after the caller's rejection but before + // this point would otherwise wait for its timeout. + reject_pending_permissions(&cancel_permissions, Some(&session_id)); + if let Some(generation) = generation { + let _ = connection + .send_notification(CancelNotification::new(session_id.clone())); + emit(AgentEvent::TurnCancelling { + session_id: session_id.clone(), + }); + let deadline = cancel_deadline_tx.clone(); + tasks.spawn(async move { + tokio::time::sleep(CANCEL_TIMEOUT).await; + let _ = deadline.send((session_id, generation)); + }); + } + } + AgentCommand::Permission { .. } => {} + } + } + let running: Vec = turns + .lock() + .map(|turns| turns.keys().cloned().collect()) + .unwrap_or_default(); + for session_id in running { + let _ = connection.send_notification(CancelNotification::new(session_id)); + } + tasks.abort_all(); + Ok(()) + }) + .await + .map_err(|error| error.to_string()); + match result { + Ok(()) if !stop_requested.load(Ordering::SeqCst) => { + Err("The Agent connection closed unexpectedly".into()) + } + other => other, + } +} + +fn internal(message: &str) -> agent_client_protocol::Error { + agent_client_protocol::util::internal_error(message) +} + +fn failed(token: Option, session_id: Option, message: String) -> AgentEvent { + AgentEvent::RequestFailed { + token, + session_id, + message, + } +} + +async fn request_with_timeout( + limit: Duration, + request: impl std::future::Future>, +) -> Result { + match tokio::time::timeout(limit, request).await { + Ok(result) => result.map_err(|error| error.to_string()), + Err(_) => Err("The Agent did not respond in time".into()), + } +} + +fn gateway_authentication(gateway: &GatewaySignIn) -> AuthenticateRequest { + let headers: serde_json::Map = gateway + .headers + .iter() + .map(|(name, value)| (name.clone(), serde_json::Value::String(value.clone()))) + .collect(); + let mut settings = serde_json::json!({ "baseUrl": gateway.base_url, "headers": headers }); + if let Some(name) = gateway + .provider_name + .as_deref() + .filter(|name| !name.is_empty()) + { + settings["providerName"] = serde_json::Value::String(name.to_owned()); + } + AuthenticateRequest::new(GATEWAY_AUTH_METHOD).meta(serde_json::Map::from_iter([( + GATEWAY_AUTH_METHOD.to_owned(), + settings, + )])) +} + +/// ACP wire name of a stop reason, e.g. `end_turn`. +fn stop_reason_name(reason: &agent_client_protocol::schema::v1::StopReason) -> String { + match serde_json::to_value(reason) { + Ok(serde_json::Value::String(name)) => name, + _ => "unknown".into(), + } +} + +async fn list_sessions( + connection: &ConnectionTo, + cwd: PathBuf, +) -> Result, String> { + let mut sessions = Vec::new(); + let mut cursor: Option = None; + for _ in 0..MAX_SESSION_LIST_PAGES { + let page = request_with_timeout( + SESSION_REQUEST_TIMEOUT, + connection + .send_request( + ListSessionsRequest::new() + .cwd(cwd.clone()) + .cursor(cursor.take()), + ) + .block_task(), + ) + .await?; + sessions.extend( + page.sessions + .into_iter() + .map(|session| AgentSessionSummary { + session_id: session.session_id.0.to_string(), + title: session.title, + updated_at: session.updated_at, + }), + ); + match page.next_cursor { + Some(next) if !next.is_empty() => cursor = Some(next), + _ => return Ok(sessions), + } + } + Ok(sessions) +} + +#[cfg(test)] +mod tests; diff --git a/rust/lithe-agent-host/src/npm-progress.mjs b/rust/lithe-agent-host/src/npm-progress.mjs new file mode 100644 index 000000000..66542af6f --- /dev/null +++ b/rust/lithe-agent-host/src/npm-progress.mjs @@ -0,0 +1,55 @@ +// Observe npm's own HTTP downloads without changing stream consumption, cache, +// registry, proxy, retries, or package installation. Only numbers reach Lithe. +import { subscribe } from "node:diagnostics_channel"; + +const originalOptions = process.env.LITHE_NPM_ORIGINAL_NODE_OPTIONS; +if (originalOptions) process.env.NODE_OPTIONS = originalOptions; +else delete process.env.NODE_OPTIONS; +delete process.env.LITHE_NPM_ORIGINAL_NODE_OPTIONS; + +const started = performance.now(); +let downloadedBytes = 0; +let activeDownloads = 0; +let previousBytes = 0; +let previousTime = started; +let lastReceived = started; +const prefix = "LITHE_NPM_PROGRESS "; +function report() { + const now = performance.now(); + const seconds = (now - previousTime) / 1000; + const bytesPerSecond = seconds > 0 ? (downloadedBytes - previousBytes) / seconds : 0; + previousBytes = downloadedBytes; + previousTime = now; + process.stderr.write(prefix + JSON.stringify({ + stage: activeDownloads > 0 ? "downloading" : downloadedBytes > 0 ? "installing" : "preparing", + downloadedBytes, + bytesPerSecond: Math.round(bytesPerSecond), + elapsedMilliseconds: Math.round(now - started), + idleMilliseconds: Math.round(now - lastReceived), + }) + "\n"); +} +subscribe("http.client.response.finish", ({ request, response }) => { + // Registry metadata, redirects, and errors are not package downloads. + if (response.statusCode < 200 || response.statusCode >= 300 || + !request.path.split("?")[0].endsWith(".tgz")) return; + activeDownloads += 1; + let finished = false; + const finish = () => { + if (!finished) { finished = true; activeDownloads -= 1; report(); } + }; + report(); + response.once("close", finish); + const push = response.push; + response.push = function (chunk, encoding) { + if (chunk !== null) { + downloadedBytes += Buffer.isBuffer(chunk) ? chunk.length : Buffer.byteLength(chunk, encoding); + lastReceived = performance.now(); + } else finish(); + // Preserve npm's own backpressure and EOF handling; never add a data listener. + return push.call(this, chunk, encoding); + }; +}); +report(); +const timer = setInterval(report, 500); +timer.unref(); +process.once("exit", () => clearInterval(timer)); diff --git a/rust/lithe-agent-host/src/prompt.rs b/rust/lithe-agent-host/src/prompt.rs new file mode 100644 index 000000000..63ce9d9e7 --- /dev/null +++ b/rust/lithe-agent-host/src/prompt.rs @@ -0,0 +1,83 @@ +//! Convert user-selected native file references to the upstream ACP content types. + +use agent_client_protocol::schema::v1::{ContentBlock, ResourceLink, TextContent}; +use serde::Deserialize; + +/// Bound one message's references without reading or allocating file contents. +const MAXIMUM_FILES: usize = 32; + +#[derive(Debug, Deserialize)] +pub struct PromptFile { + pub uri: String, + pub name: String, +} + +pub(crate) fn content(text: String, files: Vec) -> Result, String> { + if files.len() > MAXIMUM_FILES { + return Err("Attach up to 32 files per message.".into()); + } + let mut content = Vec::with_capacity(files.len() + 1); + if !text.trim().is_empty() { + content.push(ContentBlock::Text(TextContent::new(text))); + } + for file in files { + let uri = url::Url::parse(&file.uri) + .map_err(|_| "Only local files can be attached to an Agent message.".to_string())?; + let local_file = uri.scheme() == "file" + && uri + .host_str() + .is_none_or(|host| host.eq_ignore_ascii_case("localhost")) + && !uri.path().is_empty() + && uri.query().is_none() + && uri.fragment().is_none(); + if !local_file || file.name.trim().is_empty() { + return Err("Only local files can be attached to an Agent message.".into()); + } + content.push(ContentBlock::ResourceLink(ResourceLink::new( + file.name, file.uri, + ))); + } + if content.is_empty() { + return Err("Write a message or attach a file.".into()); + } + Ok(content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_local_file_uris_without_host_platform_assumptions() { + let content = content( + String::new(), + vec![PromptFile { + uri: "file:///example/project/README.md".into(), + name: "README.md".into(), + }], + ) + .expect("local file URI"); + assert!(matches!( + content.as_slice(), + [ContentBlock::ResourceLink(_)] + )); + } + + #[test] + fn rejects_remote_or_qualified_file_uris() { + for uri in [ + "file://server/share/README.md", + "file:///example/project/README.md?secret", + "file:///example/project/README.md#section", + ] { + assert!(content( + String::new(), + vec![PromptFile { + uri: uri.into(), + name: "README.md".into(), + }] + ) + .is_err()); + } + } +} diff --git a/rust/lithe-agent-host/src/session_defaults.rs b/rust/lithe-agent-host/src/session_defaults.rs new file mode 100644 index 000000000..e2b39c8b4 --- /dev/null +++ b/rust/lithe-agent-host/src/session_defaults.rs @@ -0,0 +1,198 @@ +//! Resolve stale new-session model defaults through the adapter's own catalog. +//! +//! Codex ACP retains an unknown configured model as a synthetic selector choice. +//! Its legacy model state excludes that choice, and its versioned AIR extension +//! supplies the actual recommended model. Neither fact is guessed from a name. + +use std::path::PathBuf; + +use agent_client_protocol::schema::v1::{ + NewSessionRequest, NewSessionResponse, SessionConfigKind, SessionConfigOptionCategory, + SessionConfigSelectOptions, SetSessionConfigOptionRequest, +}; +use agent_client_protocol::{Agent, ConnectionTo, JsonRpcRequest, JsonRpcResponse}; +use serde::{Deserialize, Serialize}; + +/// Preserve the adapter's optional legacy model catalog, which SDK v1 discards. +/// Standard request fields and transport remain owned by the ACP SDK. +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcRequest)] +#[request(method = "session/new", response = CatalogSessionResponse)] +struct CatalogSessionRequest { + #[serde(flatten)] + request: NewSessionRequest, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonRpcResponse)] +struct CatalogSessionResponse { + #[serde(flatten)] + session: NewSessionResponse, + #[serde(default)] + models: Option, +} + +/// Codex legacy ids are `base-model[reasoning-effort]`; selectors use the base. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyModelState { + current_model_id: String, + available_models: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyModel { + model_id: String, +} + +fn base_model(id: &str) -> &str { + id.strip_suffix(']') + .and_then(|id| id.rsplit_once('[').map(|(base, _)| base)) + .unwrap_or(id) +} + +/// A recommendation is used only with positive evidence that the current model +/// is absent from the catalog and the replacement exists in both upstream lists. +fn replacement(response: &CatalogSessionResponse) -> Option<(String, String)> { + // Optional legacy fields must not turn an otherwise valid ACP response into + // an error when a different adapter uses another model-state shape. + let models: LegacyModelState = + serde_json::from_value(response.models.as_ref()?.clone()).ok()?; + let available = |value: &str| { + models + .available_models + .iter() + .any(|model| base_model(&model.model_id) == value) + }; + let options = response.session.config_options.as_ref()?; + for option in options { + if option.category != Some(SessionConfigOptionCategory::Model) { + continue; + } + let SessionConfigKind::Select(select) = &option.kind else { + continue; + }; + let current = select.current_value.0.as_ref(); + if current != base_model(&models.current_model_id) || available(current) { + continue; + } + let air = option.meta.as_ref()?.get("jetbrains")?.get("air")?; + if air.get("version")?.as_u64()? < 1 { + continue; + } + let recommended = air.get("recommendedValue")?.as_str()?; + let selectable = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .any(|choice| choice.value.0.as_ref() == recommended), + SessionConfigSelectOptions::Grouped(groups) => groups.iter().any(|group| { + group + .options + .iter() + .any(|choice| choice.value.0.as_ref() == recommended) + }), + _ => false, + }; + if available(recommended) && selectable { + return Some((option.id.0.to_string(), recommended.to_owned())); + } + } + None +} + +/// Create and, when necessary, repair only a new session. The caller's single +/// request deadline covers both requests; history and global CLI files stay intact. +pub(crate) async fn new_session( + connection: &ConnectionTo, + cwd: PathBuf, +) -> Result { + let mut response = connection + .send_request(CatalogSessionRequest { + request: NewSessionRequest::new(cwd), + }) + .block_task() + .await?; + if let Some((id, model)) = replacement(&response) { + let configured = connection + .send_request(SetSessionConfigOptionRequest::new( + response.session.session_id.clone(), + id.clone(), + model.as_str(), + )) + .block_task() + .await?; + let confirmed = configured.config_options.iter().any(|option| { + option.id.0.as_ref() == id + && matches!(&option.kind, SessionConfigKind::Select(select) if select.current_value.0.as_ref() == model) + }); + if !confirmed { + return Err(super::internal( + "The Agent did not confirm its recommended model. Retry the conversation.", + )); + } + response.session.config_options = Some(configured.config_options); + } + Ok(response.session) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repair_requires_catalog_and_a_selectable_upstream_recommendation() { + let fixture: serde_json::Value = serde_json::from_str(include_str!( + "../../../shared/fixtures/agent/acp-events-v1.json" + )) + .unwrap(); + let initial = fixture["upstream"]["staleModelSession"].clone(); + let parsed = |value| serde_json::from_value::(value).unwrap(); + assert_eq!( + replacement(&parsed(initial.clone())), + Some(("model".into(), "model-current".into())) + ); + for mutation in [ + "validCurrent", + "noCatalog", + "malformedCatalog", + "emptyCatalog", + "noRecommendation", + "unavailableRecommendation", + "wrongVersion", + "mismatchedCurrent", + ] { + let mut value = initial.clone(); + match mutation { + "validCurrent" => value["models"]["availableModels"] + .as_array_mut() + .unwrap() + .push(serde_json::json!({"modelId": "model-retired[max]"})), + "noCatalog" => { + value.as_object_mut().unwrap().remove("models"); + } + "malformedCatalog" => { + value["models"] = serde_json::json!({"availableModels": "invalid"}) + } + "emptyCatalog" => value["models"]["availableModels"] = serde_json::json!([]), + "noRecommendation" => { + value["configOptions"][0] + .as_object_mut() + .unwrap() + .remove("_meta"); + } + "unavailableRecommendation" => { + value["configOptions"][0]["_meta"]["jetbrains"]["air"]["recommendedValue"] = + serde_json::json!("unknown") + } + "wrongVersion" => { + value["configOptions"][0]["_meta"]["jetbrains"]["air"]["version"] = + serde_json::json!(0) + } + "mismatchedCurrent" => { + value["models"]["currentModelId"] = serde_json::json!("different[max]") + } + _ => unreachable!(), + } + assert_eq!(replacement(&parsed(value)), None, "{mutation}"); + } + } +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs new file mode 100644 index 000000000..796546da3 --- /dev/null +++ b/rust/lithe-agent-host/src/tests.rs @@ -0,0 +1,1277 @@ +use super::*; +use serde_json::{json, Value}; +use tokio::io::{ + AsyncBufReadExt, AsyncWriteExt, BufReader, DuplexStream, Lines, ReadHalf, WriteHalf, +}; + +const WAIT: Duration = Duration::from_secs(5); + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../shared/fixtures/agent/acp-events-v1.json" + )) + .expect("ACP event fixture") +} + +fn provider() -> ProviderCredentials { + ProviderCredentials { + protocol: ProviderProtocol::Responses, + base_url: "https://gateway.example.com/v1".into(), + api_key: "test-key-123".into(), + name: Some("Example".into()), + model: None, + allow_insecure_http: false, + } +} + +fn gateway() -> Option { + Some(GatewaySignIn { + base_url: "https://gateway.example.com/v1".into(), + headers: vec![("Authorization".into(), "Bearer test-key-123".into())], + provider_name: Some("Example".into()), + }) +} + +fn custom_launch(command: &str, provider: ProviderCredentials) -> AgentLaunch { + AgentLaunch { + agent_id: None, + command: Some(command.into()), + args: vec![], + cwd: std::env::temp_dir(), + data_directory: None, + provider, + } +} + +/// Scripted ACP agent on the far side of an in-memory pipe. Each step is +/// driven by the test, so message order is explicit and every read is bounded. +struct MockAgent { + lines: Lines>>, + writer: WriteHalf, +} + +impl MockAgent { + async fn next(&mut self) -> Value { + let line = tokio::time::timeout(WAIT, self.lines.next_line()) + .await + .expect("client message before deadline") + .expect("readable pipe") + .expect("client kept the pipe open"); + serde_json::from_str(&line).expect("JSON-RPC line") + } + + async fn expect(&mut self, method: &str) -> Value { + let message = self.next().await; + assert_eq!(message["method"], method, "unexpected message {message}"); + message + } + + async fn write(&mut self, message: Value) { + self.writer + .write_all(format!("{message}\n").as_bytes()) + .await + .expect("write to client"); + } + + async fn reply(&mut self, request: &Value, result: Value) { + self.write(json!({ "jsonrpc": "2.0", "id": request["id"], "result": result })) + .await; + } + + async fn handshake(&mut self) { + let initialize = self.expect("initialize").await; + self.reply( + &initialize, + json!({ + "protocolVersion": 1, + "agentInfo": { "name": "mock-agent", "version": "0.1.0" }, + "agentCapabilities": { "loadSession": true, "sessionCapabilities": { "list": {} } }, + "authMethods": [{ "id": "gateway", "name": "Custom model gateway" }], + }), + ) + .await; + let authenticate = self.expect("authenticate").await; + self.reply(&authenticate, json!({})).await; + } +} + +struct Harness { + agent: MockAgent, + controls: async_mpsc::UnboundedSender, + events: async_mpsc::UnboundedReceiver, + permissions: PendingPermissions, + connection: tokio::task::JoinHandle>, +} + +impl Drop for Harness { + fn drop(&mut self) { + self.connection.abort(); + } +} + +impl Harness { + fn start() -> Self { + let (client, peer) = tokio::io::duplex(64 * 1024); + let (client_reader, client_writer) = tokio::io::split(client); + let (peer_reader, peer_writer) = tokio::io::split(peer); + let (controls, receiver) = async_mpsc::unbounded_channel(); + let (event_tx, events) = async_mpsc::unbounded_channel(); + let permissions: PendingPermissions = Arc::new(Mutex::new(HashMap::new())); + let connection = tokio::spawn(run_connection( + ByteStreams::new(client_writer.compat_write(), client_reader.compat()), + std::env::temp_dir(), + gateway(), + receiver, + permissions.clone(), + Arc::new(move |event| { + let _ = event_tx.send(event); + }), + )); + Self { + agent: MockAgent { + lines: BufReader::new(peer_reader).lines(), + writer: peer_writer, + }, + controls, + events, + permissions, + connection, + } + } + + async fn ready() -> Self { + let mut harness = Self::start(); + harness.agent.handshake().await; + assert!(matches!(harness.event().await, AgentEvent::Ready { .. })); + harness + } + + fn send(&self, command: Value) { + let command = serde_json::from_value(command).expect("command JSON"); + if let AgentCommand::Cancel { session_id } = &command { + reject_pending_permissions(&self.permissions, Some(session_id)); + } + self.controls + .send(Control::Command(command)) + .expect("connection running"); + } + + async fn event(&mut self) -> AgentEvent { + // test-stability: allow(rust-unbounded-receive) reason: tokio's async receiver has no recv_timeout; tokio::time::timeout(WAIT) bounds this await without blocking the executor. + tokio::time::timeout(WAIT, self.events.recv()) + .await + .expect("event before deadline") + .expect("event channel open") + } + + async fn open_session(&mut self, session_id: &str) { + self.send(json!({ "kind": "newSession", "token": session_id })); + let request = self.agent.expect("session/new").await; + self.agent + .reply(&request, json!({ "sessionId": session_id })) + .await; + assert!(matches!( + self.event().await, + AgentEvent::SessionCreated { .. } + )); + } + + async fn stop(mut self) -> Result<(), String> { + self.controls + .send(Control::Stop) + .expect("connection running"); + tokio::time::timeout(WAIT, &mut self.connection) + .await + .expect("connection stops before deadline") + .expect("connection task completes") + } +} + +#[test] +fn serialized_events_match_the_shared_fixture() { + let fixture = fixture(); + let events = &fixture["events"]; + let cases = [ + ( + "ready", + AgentEvent::Ready { + agent_name: Some("example-agent".into()), + agent_version: Some("1.0.0".into()), + can_load_sessions: true, + can_list_sessions: true, + }, + ), + ( + "sessionCreated", + AgentEvent::SessionCreated { + token: "token-1".into(), + session_id: "session-1".into(), + config_options: None, + }, + ), + ( + "sessionLoaded", + AgentEvent::SessionLoaded { + token: "token-2".into(), + session_id: "session-1".into(), + config_options: None, + }, + ), + ( + "sessions", + AgentEvent::Sessions { + token: "token-3".into(), + sessions: vec![ + AgentSessionSummary { + session_id: "session-1".into(), + title: Some("Explain this project".into()), + updated_at: Some("2026-09-25T10:00:00Z".into()), + }, + AgentSessionSummary { + session_id: "session-2".into(), + title: None, + updated_at: None, + }, + ], + }, + ), + ( + "turnFinished", + AgentEvent::TurnFinished { + session_id: "session-1".into(), + stop_reason: "end_turn".into(), + }, + ), + ( + "turnCancelled", + AgentEvent::TurnFinished { + session_id: "session-1".into(), + stop_reason: "cancelled".into(), + }, + ), + ( + "requestFailed", + failed( + None, + Some("session-1".into()), + "The Agent is still responding in this conversation".into(), + ), + ), + ( + "stopped", + AgentEvent::Stopped { + message: Some("The Agent connection closed unexpectedly".into()), + }, + ), + ]; + for (name, event) in cases { + assert_eq!( + serde_json::to_value(event).unwrap(), + events[name], + "fixture event {name}" + ); + } + let permission = &events["permission"]; + assert_eq!( + serde_json::to_value(AgentEvent::Permission { + session_id: "session-1".into(), + request_id: "permission-1".into(), + request: permission["request"].clone(), + }) + .unwrap(), + *permission + ); + // Update payloads are forwarded verbatim, so the fixture must be valid ACP + // wire format that the SDK round-trips unchanged. + for name in [ + "userMessageChunk", + "agentMessageChunk", + "toolCall", + "toolCallUpdate", + "sessionInfo", + "usageUpdate", + ] { + let update = &events[name]["update"]; + let parsed: agent_client_protocol::schema::v1::SessionUpdate = + serde_json::from_value(update.clone()) + .unwrap_or_else(|error| panic!("{name}: {error}")); + assert_eq!( + &serde_json::to_value(parsed).unwrap(), + update, + "fixture update {name}" + ); + } + let request: RequestPermissionRequest = + serde_json::from_value(permission["request"].clone()).expect("ACP permission request"); + assert_eq!( + serde_json::to_value(request).unwrap(), + permission["request"] + ); + assert_eq!( + stop_reason_name(&agent_client_protocol::schema::v1::StopReason::EndTurn), + "end_turn" + ); +} + +#[test] +fn management_status_matches_the_shared_fixture() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../shared/fixtures/agent/agent-management-v1.json" + )) + .expect("agent management fixture"); + let data = std::env::temp_dir().join(format!("lithe-status-{}", std::process::id())); + struct Cleanup(std::path::PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let _cleanup = Cleanup(data.clone()); + let _ = std::fs::remove_dir_all(&data); + fake_install(&data, "codex-acp"); + let tool = |version: &str, name: &str| environment::DetectedTool { + version: version.into(), + path: format!("/opt/example/node/bin/{name}").into(), + }; + let status = install::status_with( + &data, + environment::RuntimeEnvironment { + node: Some(tool("20.11.0", "node")), + npm: Some(tool("10.2.4", "npm")), + used_login_shell: true, + }, + &|command| (command == "codex").then(|| tool("0.156.1", "codex")), + &|cli| { + Some( + serde_json::from_value( + fixture["cliInstallations"][if cli.command == "codex" { + "npm" + } else { + "missing" + }] + .clone(), + ) + .unwrap(), + ) + }, + ); + assert_eq!( + serde_json::to_value(status).unwrap(), + fixture["responses"]["status"] + ); + let _ = std::fs::remove_dir_all(&data); +} + +#[test] +fn fixture_commands_parse_as_platform_commands() { + let fixture = fixture(); + let commands = fixture["commands"].as_object().expect("commands"); + for (name, command) in commands { + serde_json::from_value::(command.clone()) + .unwrap_or_else(|error| panic!("command {name}: {error}")); + } + assert!(matches!( + serde_json::from_value(commands["denyPermission"].clone()).unwrap(), + AgentCommand::Permission { + option_id: None, + .. + } + )); +} + +#[tokio::test(flavor = "current_thread")] +async fn handshake_signs_in_through_the_gateway_with_the_user_key() { + let mut harness = Harness::start(); + let initialize = harness.agent.expect("initialize").await; + assert_eq!( + initialize["params"]["clientCapabilities"]["auth"]["_meta"]["gateway"], + true + ); + harness + .agent + .reply( + &initialize, + json!({ + "protocolVersion": 1, + "agentInfo": { "name": "mock-agent", "version": "0.1.0" }, + "agentCapabilities": { "loadSession": true, "sessionCapabilities": { "list": {} } }, + "authMethods": [ + { "id": "chat-gpt", "name": "ChatGPT" }, + { "id": "gateway", "name": "Custom model gateway" } + ], + }), + ) + .await; + let authenticate = harness.agent.expect("authenticate").await; + let params = &authenticate["params"]; + assert_eq!(params["methodId"], "gateway"); + assert_eq!( + params["_meta"]["gateway"]["baseUrl"], + "https://gateway.example.com/v1" + ); + assert_eq!( + params["_meta"]["gateway"]["headers"]["Authorization"], + "Bearer test-key-123" + ); + assert_eq!(params["_meta"]["gateway"]["providerName"], "Example"); + harness.agent.reply(&authenticate, json!({})).await; + match harness.event().await { + AgentEvent::Ready { + agent_name, + can_load_sessions, + can_list_sessions, + .. + } => { + assert_eq!(agent_name.as_deref(), Some("mock-agent")); + assert!(can_load_sessions && can_list_sessions); + } + other => panic!("expected ready, got {other:?}"), + } + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn agent_without_gateway_sign_in_is_rejected_without_account_login() { + let mut harness = Harness::start(); + let initialize = harness.agent.expect("initialize").await; + harness + .agent + .reply( + &initialize, + json!({ "protocolVersion": 1, "authMethods": [{ "id": "chat-gpt", "name": "ChatGPT" }] }), + ) + .await; + let result = tokio::time::timeout(WAIT, &mut harness.connection) + .await + .expect("connection ends before deadline") + .expect("connection task completes"); + assert!(result.unwrap_err().contains("custom API key")); + assert!( + harness.events.try_recv().is_err(), + "no ready event without sign-in" + ); +} + +// A lost startup cancel must never allow another prompt into the old turn. +#[tokio::test(flavor = "current_thread")] +async fn cancel_blocks_the_next_prompt_until_acknowledged() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "first" })); + let first = harness.agent.expect("session/prompt").await; + + harness.send(json!({ "kind": "cancel", "sessionId": "session-1" })); + let cancel = harness.agent.expect("session/cancel").await; + assert_eq!(cancel["params"]["sessionId"], "session-1"); + match harness.event().await { + AgentEvent::TurnCancelling { session_id } => assert_eq!(session_id, "session-1"), + other => panic!("expected stopping turn, got {other:?}"), + } + + // A second prompt is rejected before the agent answers the first one. + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "second" })); + assert!(matches!( + harness.event().await, + AgentEvent::RequestFailed { .. } + )); + harness + .agent + .reply(&first, json!({ "stopReason": "cancelled" })) + .await; + assert!( + matches!(harness.event().await, AgentEvent::TurnFinished { stop_reason, .. } if stop_reason == "cancelled") + ); + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "second" })); + let second = harness.agent.expect("session/prompt").await; + harness + .agent + .reply(&second, json!({ "stopReason": "end_turn" })) + .await; + // Events are ordered, so a stale report for the first turn would come first. + match harness.event().await { + AgentEvent::TurnFinished { stop_reason, .. } => assert_eq!(stop_reason, "end_turn"), + other => panic!("expected the second turn to finish, got {other:?}"), + } + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn unacknowledged_cancel_ends_the_connection_with_recovery_error() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "first" })); + harness.agent.expect("session/prompt").await; + harness.send(json!({ "kind": "cancel", "sessionId": "session-1" })); + harness.agent.expect("session/cancel").await; + assert!(matches!( + harness.event().await, + AgentEvent::TurnCancelling { .. } + )); + tokio::time::advance(CANCEL_TIMEOUT).await; + let result = tokio::time::timeout(WAIT, &mut harness.connection) + .await + .unwrap() + .unwrap(); + assert!(result.unwrap_err().contains("Reconnect")); +} + +#[tokio::test(flavor = "current_thread")] +async fn session_configuration_round_trips_upstream_options() { + let mut harness = Harness::ready().await; + let options = fixture()["events"]["sessionConfigured"]["configOptions"].clone(); + harness.send(json!({ "kind": "newSession", "token": "new" })); + let request = harness.agent.expect("session/new").await; + harness + .agent + .reply( + &request, + json!({ "sessionId": "session-1", "configOptions": options }), + ) + .await; + match harness.event().await { + AgentEvent::SessionCreated { config_options, .. } => { + assert_eq!(serde_json::to_value(config_options).unwrap(), options) + } + other => panic!("expected session, got {other:?}"), + } + harness.send(fixture()["commands"]["setConfigOption"].clone()); + let request = harness.agent.expect("session/set_config_option").await; + assert_eq!(request["params"]["configId"], "model"); + assert_eq!(request["params"]["value"], "example-model"); + harness + .agent + .reply(&request, json!({ "configOptions": options })) + .await; + assert_eq!( + serde_json::to_value(harness.event().await).unwrap(), + fixture()["events"]["sessionConfigured"] + ); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn every_new_session_repairs_a_stale_model_before_publishing_options() { + let mut harness = Harness::ready().await; + let upstream = fixture()["upstream"].clone(); + for token in ["first", "second"] { + harness.send(json!({ "kind": "newSession", "token": token })); + let request = harness.agent.expect("session/new").await; + harness + .agent + .reply(&request, upstream["staleModelSession"].clone()) + .await; + let repair = harness.agent.expect("session/set_config_option").await; + assert_eq!(repair["params"]["sessionId"], "session-repaired"); + assert_eq!(repair["params"]["configId"], "model"); + assert_eq!(repair["params"]["value"], "model-current"); + assert!( + harness.events.try_recv().is_err(), + "a session must not be exposed before confirmation" + ); + harness + .agent + .reply(&repair, upstream["repairedModelConfiguration"].clone()) + .await; + let event = serde_json::to_value(harness.event().await).unwrap(); + assert_eq!(event["kind"], "sessionCreated"); + assert_eq!(event["token"], token); + assert_eq!( + event["configOptions"], + upstream["repairedModelConfiguration"]["configOptions"] + ); + } + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn model_repair_rejection_does_not_publish_a_fake_success() { + let mut harness = Harness::ready().await; + harness.send(json!({ "kind": "newSession", "token": "new" })); + let request = harness.agent.expect("session/new").await; + harness + .agent + .reply(&request, fixture()["upstream"]["staleModelSession"].clone()) + .await; + let repair = harness.agent.expect("session/set_config_option").await; + harness.agent.write(json!({ "jsonrpc": "2.0", "id": repair["id"], "error": {"code": -32602, "message": "Model rejected"} })).await; + let event = serde_json::to_value(harness.event().await).unwrap(); + assert_eq!(event["kind"], "requestFailed"); + assert_eq!(event["token"], "new"); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn model_repair_requires_the_upstream_to_confirm_the_new_value() { + let mut harness = Harness::ready().await; + harness.send(json!({ "kind": "newSession", "token": "new" })); + let request = harness.agent.expect("session/new").await; + let stale = fixture()["upstream"]["staleModelSession"].clone(); + harness.agent.reply(&request, stale.clone()).await; + let repair = harness.agent.expect("session/set_config_option").await; + harness + .agent + .reply(&repair, json!({"configOptions": stale["configOptions"]})) + .await; + let event = serde_json::to_value(harness.event().await).unwrap(); + assert_eq!(event["kind"], "requestFailed"); + assert!(event["message"] + .as_str() + .unwrap() + .contains("did not confirm")); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread", start_paused = true)] +async fn model_repair_shares_the_bounded_session_creation_deadline() { + let mut harness = Harness::ready().await; + harness.send(json!({ "kind": "newSession", "token": "new" })); + let request = harness.agent.expect("session/new").await; + harness + .agent + .reply(&request, fixture()["upstream"]["staleModelSession"].clone()) + .await; + harness.agent.expect("session/set_config_option").await; + tokio::time::advance(SESSION_REQUEST_TIMEOUT).await; + let event = serde_json::to_value(harness.event().await).unwrap(); + assert_eq!(event["kind"], "requestFailed"); + assert_eq!(event["token"], "new"); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn updates_and_turns_are_routed_to_their_own_sessions() { + let mut harness = Harness::ready().await; + harness.open_session("session-a").await; + harness.open_session("session-b").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-a", "text": "a" })); + let prompt_a = harness.agent.expect("session/prompt").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-b", "text": "b" })); + let prompt_b = harness.agent.expect("session/prompt").await; + for session in ["session-b", "session-a"] { + harness + .agent + .write(json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": session, + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": session } } + } + })) + .await; + } + for expected in ["session-b", "session-a"] { + match harness.event().await { + AgentEvent::Update { session_id, update } => { + assert_eq!(session_id, expected); + assert_eq!(update["content"]["text"], expected); + } + other => panic!("expected update, got {other:?}"), + } + } + harness + .agent + .reply(&prompt_b, json!({ "stopReason": "end_turn" })) + .await; + harness + .agent + .reply(&prompt_a, json!({ "stopReason": "max_tokens" })) + .await; + let finished: Vec<(String, String)> = [harness.event().await, harness.event().await] + .into_iter() + .map(|event| match event { + AgentEvent::TurnFinished { + session_id, + stop_reason, + } => (session_id, stop_reason), + other => panic!("expected turn end, got {other:?}"), + }) + .collect(); + assert_eq!( + finished, + [ + ("session-b".into(), "end_turn".into()), + ("session-a".into(), "max_tokens".into()) + ] + ); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn permission_is_answered_by_the_user_and_rejected_by_cancel() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "run" })); + let _prompt = harness.agent.expect("session/prompt").await; + let permission_request = |id: u64| { + json!({ + "jsonrpc": "2.0", "id": id, "method": "session/request_permission", + "params": { + "sessionId": "session-1", + "toolCall": { "toolCallId": "call-1", "title": "Run tests" }, + "options": [{ "optionId": "allow_once", "name": "Allow Once", "kind": "allow_once" }] + } + }) + }; + + harness.agent.write(permission_request(900)).await; + let request_id = match harness.event().await { + AgentEvent::Permission { + session_id, + request_id, + .. + } => { + assert_eq!(session_id, "session-1"); + request_id + } + other => panic!("expected permission, got {other:?}"), + }; + answer_permission(&harness.permissions, &request_id, Some("allow_once".into())).unwrap(); + let answer = harness.agent.next().await; + assert_eq!(answer["id"], 900); + assert_eq!(answer["result"]["outcome"]["outcome"], "selected"); + assert_eq!(answer["result"]["outcome"]["optionId"], "allow_once"); + + harness.agent.write(permission_request(901)).await; + let request_id = match harness.event().await { + AgentEvent::Permission { request_id, .. } => request_id, + other => panic!("expected permission, got {other:?}"), + }; + harness.send(json!({ "kind": "cancel", "sessionId": "session-1" })); + let answer = harness.agent.next().await; + assert_eq!(answer["id"], 901); + assert_eq!(answer["result"]["outcome"]["outcome"], "cancelled"); + assert!(answer_permission(&harness.permissions, &request_id, None).is_err()); + let _cancel = harness.agent.expect("session/cancel").await; + + // With no running turn, a late request is refused without asking the user. + harness.agent.write(permission_request(902)).await; + let answer = harness.agent.next().await; + assert_eq!(answer["id"], 902); + assert_eq!(answer["result"]["outcome"]["outcome"], "cancelled"); + assert!(matches!( + harness.event().await, + AgentEvent::TurnCancelling { .. } + )); + assert!( + harness.events.try_recv().is_err(), + "late request must not reach the UI" + ); + assert_eq!(harness.stop().await, Ok(())); +} + +// Regression: a permission request registered after the handle rejected the +// session's pending requests, but before the loop processed the cancel, must +// still be answered instead of waiting for its five-minute timeout. +#[tokio::test(flavor = "current_thread")] +async fn cancel_also_rejects_a_permission_registered_after_the_handle_check() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "run" })); + let _prompt = harness.agent.expect("session/prompt").await; + harness + .agent + .write(json!({ + "jsonrpc": "2.0", "id": 950, "method": "session/request_permission", + "params": { + "sessionId": "session-1", + "toolCall": { "toolCallId": "call-1", "title": "Run tests" }, + "options": [{ "optionId": "allow_once", "name": "Allow Once", "kind": "allow_once" }] + } + })) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::Permission { .. } + )); + // Queue the cancel directly, skipping the handle's own rejection. + let cancel = + serde_json::from_value(json!({ "kind": "cancel", "sessionId": "session-1" })).unwrap(); + harness + .controls + .send(Control::Command(cancel)) + .expect("connection running"); + // The protocol does not order the cancel notification and the permission + // reply, which are written by different tasks. + let messages = [harness.agent.next().await, harness.agent.next().await]; + let answer = messages + .iter() + .find(|m| m["id"] == 950) + .expect("permission answered"); + assert_eq!(answer["result"]["outcome"]["outcome"], "cancelled"); + assert!(messages.iter().any(|m| m["method"] == "session/cancel")); + assert!(matches!( + harness.event().await, + AgentEvent::TurnCancelling { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn session_history_is_listed_across_pages_and_loaded() { + let mut harness = Harness::ready().await; + harness.send(json!({ "kind": "listSessions", "token": "list" })); + let first = harness.agent.expect("session/list").await; + assert!(first["params"]["cwd"].is_string()); + harness + .agent + .reply(&first, json!({ "sessions": [{ "sessionId": "s1", "cwd": "/w", "title": "One" }], "nextCursor": "page-2" })) + .await; + let second = harness.agent.expect("session/list").await; + assert_eq!(second["params"]["cursor"], "page-2"); + harness + .agent + .reply( + &second, + json!({ "sessions": [{ "sessionId": "s2", "cwd": "/w" }] }), + ) + .await; + match harness.event().await { + AgentEvent::Sessions { token, sessions } => { + assert_eq!(token, "list"); + let ids: Vec<&str> = sessions.iter().map(|s| s.session_id.as_str()).collect(); + assert_eq!(ids, ["s1", "s2"]); + } + other => panic!("expected sessions, got {other:?}"), + } + + harness.send(json!({ "kind": "loadSession", "token": "load", "sessionId": "s1" })); + let load = harness.agent.expect("session/load").await; + assert_eq!(load["params"]["sessionId"], "s1"); + harness.agent.reply(&load, json!({})).await; + match harness.event().await { + AgentEvent::SessionLoaded { + token, session_id, .. + } => { + assert_eq!((token.as_str(), session_id.as_str()), ("load", "s1")) + } + other => panic!("expected loaded session, got {other:?}"), + } + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn a_busy_session_rejects_a_second_prompt_without_stopping() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "first" })); + let first = harness.agent.expect("session/prompt").await; + harness.send(json!({ "kind": "prompt", "sessionId": "session-1", "text": "again" })); + match harness.event().await { + AgentEvent::RequestFailed { session_id, .. } => { + assert_eq!(session_id.as_deref(), Some("session-1")) + } + other => panic!("expected request failure, got {other:?}"), + } + harness + .agent + .reply(&first, json!({ "stopReason": "end_turn" })) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::TurnFinished { .. } + )); + assert_eq!(harness.stop().await, Ok(())); +} + +#[tokio::test(flavor = "current_thread")] +async fn agent_exit_without_a_stop_request_is_reported_as_failure() { + let mut harness = Harness::ready().await; + harness + .agent + .writer + .shutdown() + .await + .expect("close agent output"); + let result = tokio::time::timeout(WAIT, &mut harness.connection) + .await + .expect("connection ends before deadline") + .expect("connection task completes"); + assert!(result.is_err()); +} + +#[test] +fn stderr_tail_keeps_recent_lines_and_redacts_the_key() { + let mut tail = StderrTail::default(); + for index in 0..40 { + tail.push(format!("line {index}\n").as_bytes()); + } + tail.push(b"auth failed for sk-secret\n"); + let summary = tail.summary("sk-secret").expect("stderr summary"); + assert!(!summary.contains("sk-secret")); + assert!(summary.ends_with("auth failed for ")); + assert!(!summary.contains("line 0\n")); + assert_eq!(summary.lines().count(), STDERR_TAIL_LINES); + + let mut large = StderrTail::default(); + large.push(&vec![b'x'; STDERR_TAIL_BYTES * 2]); + assert_eq!(large.0.len(), STDERR_TAIL_BYTES); + assert!(StderrTail::default().summary("key").is_none()); +} + +#[test] +fn provider_endpoints_are_normalized_per_protocol_and_must_be_secure() { + let endpoint = |url: &str, insecure: bool| ProviderCredentials { + base_url: url.into(), + allow_insecure_http: insecure, + ..provider() + }; + for url in [ + "https://host.example/v1", + "https://host.example/v1/", + " https://host.example/v1/responses ", + ] { + assert_eq!( + endpoint(url, false).responses_base_url().unwrap(), + "https://host.example/v1" + ); + } + for url in [ + "https://api.example", + "https://api.example/v1", + "https://api.example/v1/messages/", + ] { + assert_eq!( + endpoint(url, false).anthropic_base_url().unwrap(), + "https://api.example" + ); + } + assert!(endpoint("http://localhost:1234/v1", false) + .responses_base_url() + .is_err()); + assert_eq!( + endpoint("http://localhost:1234/v1", true) + .responses_base_url() + .unwrap(), + "http://localhost:1234/v1" + ); + for url in [ + "", + "host.example/v1", + "https://", + "https://host.example/v1?key=1", + "ftp://host", + ] { + assert!(endpoint(url, true).responses_base_url().is_err(), "{url}"); + } +} + +#[test] +fn provider_debug_output_never_contains_the_key() { + let rendered = format!("{:?}", provider()); + assert!(!rendered.contains("test-key-123")); + assert!(rendered.contains("")); +} + +#[test] +fn child_path_puts_the_agent_directory_before_the_search_path() { + let directory = std::env::temp_dir().join("lithe-agent-bin"); + let base = std::env::join_paths(["/shell/node/bin", "/usr/bin"]).unwrap(); + let path = child_path(&directory.join("codex-acp"), Some(base)).expect("PATH"); + let entries: Vec = std::env::split_paths(&path).collect(); + assert_eq!( + entries, + [directory, "/shell/node/bin".into(), "/usr/bin".into()] + ); + assert!(child_path(Path::new("codex-acp"), None).is_none()); +} + +/// Install a fake adapter the way `install` leaves it, without running npm. +fn fake_install(data: &Path, agent_id: &str) { + let agent = catalog::find(agent_id).unwrap(); + let command = install::installed_command(data, agent); + std::fs::create_dir_all(command.parent().unwrap()).unwrap(); + std::fs::write(&command, "#!/bin/sh\n").unwrap(); + std::fs::write( + install::agent_directory(data, agent).join("lithe-agent.json"), + format!(r#"{{"id":"{agent_id}","version":"{}"}}"#, agent.version), + ) + .unwrap(); +} + +#[test] +fn catalog_agents_resolve_to_their_install_and_key_delivery() { + let data = std::env::temp_dir().join(format!("lithe-resolve-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&data); + let launch = |agent_id: &str, provider: ProviderCredentials| AgentLaunch { + agent_id: Some(agent_id.into()), + command: None, + args: vec![], + cwd: std::env::temp_dir(), + data_directory: Some(data.clone()), + provider, + }; + let anthropic = ProviderCredentials { + protocol: ProviderProtocol::AnthropicMessages, + base_url: "https://api.example/v1/messages".into(), + ..provider() + }; + let codex_cli = |command: &str| match command { + "codex" => Some(environment::DetectedTool { + version: "0.156.1".into(), + path: "/opt/example/bin/codex".into(), + }), + "claude" => Some(environment::DetectedTool { + version: "2.1.282".into(), + path: "/opt/example/bin/claude".into(), + }), + _ => None, + }; + let resolve = |launch: AgentLaunch| resolve_with(launch, &codex_cli); + let not_installed = resolve(launch("codex-acp", provider())).err().unwrap(); + assert!(not_installed.contains("not installed"), "{not_installed}"); + fake_install(&data, "codex-acp"); + fake_install(&data, "claude-acp"); + + let codex = resolve(launch("codex-acp", provider())).unwrap(); + assert!(codex.command.ends_with("node_modules/.bin/codex-acp") || cfg!(windows)); + // The adapter drives the user's own Codex; the key never enters the environment. + assert_eq!( + codex.env, + [("CODEX_PATH".to_owned(), "/opt/example/bin/codex".to_owned())] + ); + assert_eq!( + codex.gateway.unwrap().base_url, + "https://gateway.example.com/v1" + ); + let with_model = resolve(launch( + "codex-acp", + ProviderCredentials { + model: Some(" gpt-5.5 ".into()), + ..provider() + }, + )) + .unwrap(); + assert_eq!( + with_model.env, + [ + ("CODEX_PATH".to_owned(), "/opt/example/bin/codex".to_owned()), + ( + "CODEX_CONFIG".to_owned(), + r#"{"model":"gpt-5.5"}"#.to_owned() + ), + ] + ); + let missing_cli = resolve_with(launch("codex-acp", provider()), &|_| None) + .err() + .unwrap(); + assert!( + missing_cli.contains("Codex CLI was not found"), + "{missing_cli}" + ); + let old_cli = resolve_with(launch("codex-acp", provider()), &|_| { + Some(environment::DetectedTool { + version: "0.150.0".into(), + path: "/opt/example/bin/codex".into(), + }) + }) + .err() + .unwrap(); + assert!(old_cli.contains("0.156.0 or later"), "{old_cli}"); + assert!(!with_model + .env + .iter() + .any(|(_, value)| value.contains("test-key-123"))); + + let claude = resolve(launch( + "claude-acp", + ProviderCredentials { + model: Some("claude-sonnet-5".into()), + ..anthropic + }, + )) + .unwrap(); + // Claude signs in through the gateway too, in the Anthropic dialect; the + // key never enters the adapter's environment. + let sign_in = claude.gateway.expect("gateway sign-in"); + assert_eq!(sign_in.base_url, "https://api.example"); + assert_eq!( + sign_in.headers, + [("x-api-key".to_owned(), "test-key-123".to_owned())] + ); + assert_eq!( + claude.env, + [ + ( + "CLAUDE_CODE_EXECUTABLE".to_owned(), + "/opt/example/bin/claude".to_owned() + ), + ("ANTHROPIC_MODEL".to_owned(), "claude-sonnet-5".to_owned()), + ] + ); + let mismatch = resolve(launch("claude-acp", provider())).err().unwrap(); + assert!(mismatch.contains("Anthropic Messages"), "{mismatch}"); + assert!(resolve(launch("unknown", provider())).is_err()); + let _ = std::fs::remove_dir_all(&data); +} + +#[test] +fn invalid_settings_are_reported_without_starting_a_process() { + let launch = |command: &str, key: &str, url: &str, protocol: ProviderProtocol| { + custom_launch( + command, + ProviderCredentials { + protocol, + api_key: key.into(), + base_url: url.into(), + ..provider() + }, + ) + }; + let responses = ProviderProtocol::Responses; + let cases = [ + (launch(" ", "key", "https://h/v1", responses), "executable"), + ( + launch( + "/lithe/nonexistent-acp-agent", + " ", + "https://h/v1", + responses, + ), + "API key", + ), + ( + launch( + "/lithe/nonexistent-acp-agent", + "key", + "http://h/v1", + responses, + ), + "https", + ), + ( + launch( + "/lithe/nonexistent-acp-agent", + "key", + "https://h", + ProviderProtocol::AnthropicMessages, + ), + "Responses API", + ), + ]; + for (launch, expected) in cases { + let (sender, receiver) = mpsc::channel(); + let handle = AgentHandle::open( + launch, + Arc::new(move |event| { + let _ = sender.send(event); + }), + ) + .expect("worker starts"); + match receiver.recv_timeout(WAIT) { + Ok(AgentEvent::Stopped { + message: Some(message), + }) => { + assert!(message.contains(expected), "{message}"); + assert!( + !message.contains("Could not start"), + "no process for invalid settings" + ); + } + other => panic!("expected stopped, got {other:?}"), + } + handle.close(); + } +} + +#[test] +fn spawn_failure_reports_stopped_with_a_message() { + let (sender, receiver) = mpsc::channel(); + let launch = custom_launch("/lithe/nonexistent-acp-agent", provider()); + let handle = AgentHandle::open( + launch, + Arc::new(move |event| { + let _ = sender.send(event); + }), + ) + .expect("worker starts"); + match receiver.recv_timeout(WAIT) { + Ok(AgentEvent::Stopped { + message: Some(message), + }) => { + assert!(message.contains("Could not start the Agent")); + } + other => panic!("expected stopped with message, got {other:?}"), + } + handle.close(); +} + +// Protect the actual SDK wire boundary, not just the local command DTO. +#[tokio::test] +async fn selected_files_reach_the_agent_as_acp_resource_links() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + let fixture = fixture(); + for name in ["promptWithFiles", "fileOnlyPrompt"] { + harness.send(fixture["commands"][name].clone()); + let request = harness.agent.expect("session/prompt").await; + let content = request["params"]["prompt"] + .as_array() + .expect("prompt content"); + let files = fixture["commands"][name]["files"].as_array().unwrap(); + let offset = usize::from(name == "promptWithFiles"); + assert_eq!(content.len(), files.len() + offset); + if offset == 1 { + assert_eq!( + content[0], + json!({ "type": "text", "text": "Explain these files" }) + ); + } + for (actual, file) in content[offset..].iter().zip(files) { + assert_eq!( + actual, + &json!({ "type": "resource_link", "uri": file["uri"], "name": file["name"] }) + ); + } + harness + .agent + .reply(&request, json!({ "stopReason": "end_turn" })) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::TurnFinished { .. } + )); + } + harness.stop().await.expect("owned connection stopped"); +} + +#[tokio::test] +async fn invalid_file_reference_does_not_reserve_or_finish_a_turn() { + let mut harness = Harness::ready().await; + harness.open_session("session-1").await; + for files in [ + json!([{ "uri": "https://example.com/file.txt", "name": "file.txt" }]), + json!([{ "uri": "file:///example/file.txt?secret", "name": "file.txt" }]), + json!((0..33) + .map( + |index| json!({ "uri": format!("file:///example/{index}.txt"), "name": "file.txt" }) + ) + .collect::>()), + ] { + harness.send( + json!({ "kind": "prompt", "sessionId": "session-1", "text": "Read", "files": files }), + ); + assert!(matches!( + harness.event().await, + AgentEvent::RequestFailed { + session_id: Some(_), + .. + } + )); + } + harness.send(fixture()["commands"]["prompt"].clone()); + let request = harness.agent.expect("session/prompt").await; + assert_eq!( + request["params"]["prompt"], + json!([{ "type": "text", "text": "Explain this project" }]) + ); + harness + .agent + .reply(&request, json!({ "stopReason": "end_turn" })) + .await; + assert!(matches!( + harness.event().await, + AgentEvent::TurnFinished { .. } + )); + harness.stop().await.expect("owned connection stopped"); +} diff --git a/rust/lithe-agent-host/tests/npm-progress.test.mjs b/rust/lithe-agent-host/tests/npm-progress.test.mjs new file mode 100644 index 000000000..4ff42d326 --- /dev/null +++ b/rust/lithe-agent-host/tests/npm-progress.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import http from 'node:http'; +import test from 'node:test'; + +// Local HTTP only: prove byte accounting does not consume or modify npm's stream. +test('npm observer counts archive bytes and preserves Node options and response data', { timeout: 5000 }, async (t) => { + const archive = Buffer.alloc(256 * 1024, 37); + const server = http.createServer((request, response) => { + if (request.url === '/redirect.tgz') { + response.writeHead(302, { location: '/package.tgz' }); + response.end('not a package'); + } else if (request.url === '/package.tgz') { + // Deliberately omit Content-Length; progress must not invent a total. + response.writeHead(200); + response.end(archive); + } else response.end('registry metadata'); + }); + t.after(() => { server.closeAllConnections(); server.close(); }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const source = await readFile(new URL('../src/npm-progress.mjs', import.meta.url), 'utf8'); + const observer = `data:text/javascript,${encodeURIComponent(source)}`; + const port = server.address().port; + const script = ` + const http = require('node:http'); + async function download(path) { + return await new Promise((resolve, reject) => { + http.get('http://127.0.0.1:${port}' + path, response => { + const body = []; + response.on('data', data => body.push(data)); + response.on('end', () => resolve(Buffer.concat(body))); + response.on('error', reject); + }).on('error', reject); + }); + } + (async () => { + await download('/metadata'); + await download('/redirect.tgz'); + const bytes = await download('/package.tgz'); + console.log(JSON.stringify({ length: bytes.length, valid: bytes.every(b => b === 37), options: process.env.NODE_OPTIONS })); + })().catch(error => { console.error(error); process.exitCode = 1; }); + `; + const { stdout, stderr } = await new Promise((resolve, reject) => { + execFile(process.execPath, ['-e', script], { + timeout: 4000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, + env: { ...process.env, NODE_OPTIONS: `--max-old-space-size=256 --import=${observer}`, + LITHE_NPM_ORIGINAL_NODE_OPTIONS: '--max-old-space-size=256' }, + }, (error, stdout, stderr) => error ? reject(error) : resolve({ stdout, stderr })); + }); + assert.deepEqual(JSON.parse(stdout), { length: archive.length, valid: true, options: '--max-old-space-size=256' }); + const events = stderr.split('\n').filter(line => line.startsWith('LITHE_NPM_PROGRESS ')) + .map(line => JSON.parse(line.slice('LITHE_NPM_PROGRESS '.length))); + assert.ok(events.some(event => event.stage === 'downloading')); + assert.equal(events.at(-1).downloadedBytes, archive.length); + assert.equal(events.at(-1).stage, 'installing'); + assert.ok(events.every(event => !('totalBytes' in event) && !('url' in event))); +}); diff --git a/rust/lithe-agent-host/tests/real_agent.rs b/rust/lithe-agent-host/tests/real_agent.rs new file mode 100644 index 000000000..0c9300b46 --- /dev/null +++ b/rust/lithe-agent-host/tests/real_agent.rs @@ -0,0 +1,426 @@ +//! Opt-in end-to-end check against a real ACP agent and API gateway. +//! +//! Ignored by default because it needs an installed agent, network access, and +//! a real key. Run it explicitly with: +//! +//! ```text +//! LITHE_ACP_E2E_COMMAND=/path/to/codex-acp \ +//! LITHE_ACP_E2E_BASE_URL=https://host/v1 LITHE_ACP_E2E_API_KEY=... \ +//! cargo test -p lithe-agent-host --test real_agent -- --ignored --nocapture +//! ``` +//! +//! `LITHE_ACP_E2E_MODEL` optionally selects the provider model and +//! `LITHE_ACP_E2E_ARGS` holds newline-separated arguments. With +//! `LITHE_ACP_E2E_DATA_DIR` set instead of a command, the Codex adapter is +//! installed there with the user's npm (if missing) and launched as a catalog +//! agent, covering the one-click install path. + +use std::sync::{mpsc, Arc}; +use std::time::{Duration, Instant}; + +use lithe_agent_host::{ + install, AgentCommand, AgentEvent, AgentHandle, AgentLaunch, ProviderCredentials, + ProviderProtocol, +}; + +const TURN_DEADLINE: Duration = Duration::from_secs(180); + +struct Session { + handle: AgentHandle, + events: mpsc::Receiver, +} + +impl Session { + fn next(&self, deadline: Instant, what: &str) -> AgentEvent { + let remaining = deadline.saturating_duration_since(Instant::now()); + self.events + .recv_timeout(remaining) + .unwrap_or_else(|_| panic!("timed out waiting for {what}")) + } + + /// Wait for the first event accepted by `select`, collecting agent text for + /// `session_id` on the way. + fn wait( + &self, + what: &str, + session_id: Option<&str>, + text: &mut String, + mut select: impl FnMut(&AgentEvent) -> Option, + ) -> T { + let deadline = Instant::now() + TURN_DEADLINE; + loop { + let event = self.next(deadline, what); + if let AgentEvent::Update { + session_id: id, + update, + } = &event + { + if Some(id.as_str()) == session_id + && update["sessionUpdate"] == "agent_message_chunk" + { + text.push_str(update["content"]["text"].as_str().unwrap_or_default()); + } + } + if let AgentEvent::Stopped { message } = &event { + panic!("agent stopped while waiting for {what}: {message:?}"); + } + if let AgentEvent::RequestFailed { message, .. } = &event { + panic!("request failed while waiting for {what}: {message}"); + } + if let Some(value) = select(&event) { + return value; + } + } + } + + fn send(&self, command: AgentCommand) { + self.handle + .send(command) + .expect("connection accepts command"); + } + + fn new_session(&self, token: &str) -> String { + self.send(AgentCommand::NewSession { + token: token.into(), + }); + self.wait( + "session creation", + None, + &mut String::new(), + |event| match event { + AgentEvent::SessionCreated { + token: t, + session_id, + .. + } if t == token => Some(session_id.clone()), + _ => None, + }, + ) + } + + fn prompt(&self, session_id: &str, text: &str) -> (String, String) { + self.send(AgentCommand::Prompt { + session_id: session_id.into(), + text: text.into(), + files: vec![], + }); + let mut reply = String::new(); + let reason = self.wait( + "turn end", + Some(session_id), + &mut reply, + |event| match event { + AgentEvent::TurnFinished { + session_id: id, + stop_reason, + } if id == session_id => Some(stop_reason.clone()), + _ => None, + }, + ); + (reason, reply) + } +} + +fn required(name: &str) -> String { + std::env::var(name).unwrap_or_else(|_| panic!("{name} must be set for this ignored test")) +} + +fn open(workspace: &std::path::Path) -> Session { + let (sender, events) = mpsc::channel(); + let data_directory = std::env::var_os("LITHE_ACP_E2E_DATA_DIR").map(std::path::PathBuf::from); + if let Some(data) = &data_directory { + if install::installed_version(data, lithe_agent_host::catalog::find("codex-acp").unwrap()) + .is_none() + { + install::install(data, "codex-acp", &|| false).expect("adapter installs with npm"); + } + } + let launch = AgentLaunch { + agent_id: data_directory.as_ref().map(|_| "codex-acp".to_owned()), + command: std::env::var("LITHE_ACP_E2E_COMMAND").ok(), + args: std::env::var("LITHE_ACP_E2E_ARGS") + .map(|args| { + args.lines() + .filter(|line| !line.is_empty()) + .map(String::from) + .collect() + }) + .unwrap_or_default(), + cwd: workspace.to_path_buf(), + data_directory, + provider: ProviderCredentials { + protocol: ProviderProtocol::Responses, + base_url: required("LITHE_ACP_E2E_BASE_URL"), + api_key: required("LITHE_ACP_E2E_API_KEY"), + name: Some("Lithe end-to-end test".into()), + model: std::env::var("LITHE_ACP_E2E_MODEL").ok(), + allow_insecure_http: false, + }, + }; + let handle = AgentHandle::open( + launch, + Arc::new(move |event| { + let _ = sender.send(event); + }), + ) + .expect("valid launch configuration"); + let session = Session { handle, events }; + session.wait("ready", None, &mut String::new(), |event| { + matches!(event, AgentEvent::Ready { .. }).then_some(()) + }); + session +} + +#[test] +#[ignore = "requires a real ACP agent, network access, and an API key"] +fn real_agent_conversation_cancel_history_and_cleanup() { + let workspace = TemporaryProject::new(); + let session = open(&workspace.0); + + let first = session.new_session("first"); + let (reason, reply) = session.prompt( + &first, + "Remember the code word LITHE-HERON. Reply with exactly: STORED", + ); + assert_eq!(reason, "end_turn"); + assert!(reply.contains("STORED"), "unexpected reply {reply:?}"); + + // A lost startup cancel must result in bounded connection recovery, never + // an immediately accepted second prompt in the same unfinished turn. + let second = session.new_session("second"); + session.send(AgentCommand::Prompt { + session_id: second.clone(), + text: "Write a numbered list of 40 facts about the ocean. Do not use tools.".into(), + files: vec![], + }); + session.send(AgentCommand::Cancel { + session_id: second.clone(), + }); + let started = Instant::now(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + match session.next(deadline, "cancellation or recovery") { + AgentEvent::TurnFinished { + session_id, + stop_reason, + } if session_id == second => { + assert!(["cancelled", "end_turn"].contains(&stop_reason.as_str())); + break; + } + AgentEvent::Stopped { message } => { + assert!(message.unwrap_or_default().contains("Reconnect")); + break; + } + _ => {} + } + } + assert!(started.elapsed() < Duration::from_secs(20)); + session.handle.close(); + + let resumed = open(&workspace.0); + resumed.send(AgentCommand::ListSessions { + token: "list".into(), + }); + let listed = resumed.wait( + "session list", + None, + &mut String::new(), + |event| match event { + AgentEvent::Sessions { sessions, .. } => Some( + sessions + .iter() + .map(|s| s.session_id.clone()) + .collect::>(), + ), + _ => None, + }, + ); + assert!( + listed.contains(&first), + "first session missing from {listed:?}" + ); + // A new process resumes the first conversation with its context. + resumed.send(AgentCommand::LoadSession { + token: "load".into(), + session_id: first.clone(), + }); + resumed.wait("session load", None, &mut String::new(), |event| { + matches!(event, AgentEvent::SessionLoaded { .. }).then_some(()) + }); + let (reason, reply) = resumed.prompt( + &first, + "What code word did I ask you to remember? Reply with the code word only.", + ); + assert_eq!(reason, "end_turn"); + assert!( + reply.contains("LITHE-HERON"), + "context was not restored: {reply:?}" + ); + resumed.handle.close(); +} + +struct TemporaryProject(std::path::PathBuf); + +impl TemporaryProject { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("lithe-acp-workflow-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&path).expect("temporary project"); + Self(path) + } +} + +impl Drop for TemporaryProject { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +#[ignore = "requires a real ACP agent, network access, Node.js, and an API key"] +fn real_agent_reads_edits_tests_and_continues_in_temporary_project() { + let workspace = TemporaryProject::new(); + std::fs::write( + workspace.0.join("sum.cjs"), + "module.exports = (a, b) => a - b;\n", + ) + .unwrap(); + std::fs::write(workspace.0.join("sum.test.cjs"), + "const assert = require('node:assert/strict');\nconst sum = require('./sum.cjs');\nassert.equal(sum(2, 3), 5);\nconsole.log('LITHE_TEST_PASSED');\n").unwrap(); + let session = open(&workspace.0); + let id = session.new_session("workflow"); + session.send(AgentCommand::Prompt { + session_id: id.clone(), + text: "Work only in this temporary project. Read sum.cjs and sum.test.cjs, fix the implementation, then execute node sum.test.cjs. Do not install dependencies, access network, or change any file outside this directory. Report the actual test result.".into(), + files: vec![], + }); + let mut tools = 0; + let mut result_seen = false; + let mut reply = String::new(); + let reason = session.wait("read/edit/test workflow", Some(&id), &mut reply, |event| { + match event { + AgentEvent::Permission { + request_id, + request, + .. + } => { + let option = request["options"] + .as_array() + .and_then(|options| options.iter().find(|o| o["kind"] == "allow_once")) + .and_then(|o| o["optionId"].as_str()) + .map(String::from); + session.send(AgentCommand::Permission { + request_id: request_id.clone(), + option_id: option, + }); + } + AgentEvent::Update { update, .. } => { + if update["sessionUpdate"] == "tool_call" { + tools += 1; + } + if update["sessionUpdate"] == "tool_call_update" + && update.to_string().contains("LITHE_TEST_PASSED") + { + result_seen = true; + } + } + AgentEvent::TurnFinished { stop_reason, .. } => return Some(stop_reason.clone()), + _ => {} + } + None + }); + assert_eq!(reason, "end_turn"); + assert!(tools >= 2, "expected file and execution tools"); + assert!(result_seen, "tool output must contain the test marker"); + assert!(!std::fs::read_to_string(workspace.0.join("sum.cjs")) + .unwrap() + .contains("a - b")); + assert_eq!( + std::fs::read_dir(&workspace.0).unwrap().count(), + 2, + "no extra generated files" + ); + let (reason, reply) = session.prompt( + &id, + "What command did you just execute and did it pass? Do not use tools.", + ); + assert_eq!(reason, "end_turn"); + assert!(reply.contains("sum.test.cjs")); + session.handle.close(); +} + +#[test] +#[ignore = "requires a real Codex ACP agent and API key configuration"] +fn real_agent_configuration_options_are_selectable() { + let workspace = TemporaryProject::new(); + let session = open(&workspace.0); + session.send(AgentCommand::NewSession { + token: "config".into(), + }); + let (id, options) = + session.wait( + "session configuration", + None, + &mut String::new(), + |event| match event { + AgentEvent::SessionCreated { + session_id, + config_options, + .. + } => Some(( + session_id.clone(), + serde_json::to_value(config_options).unwrap(), + )), + _ => None, + }, + ); + let options = options.as_array().expect("Codex reports config options"); + for category in ["model", "mode", "thought_level"] { + let option = options + .iter() + .find(|option| option["category"] == category) + .unwrap_or_else(|| panic!("missing category {category}")); + let config_id = option["id"].as_str().unwrap(); + let current = option["currentValue"].as_str().unwrap(); + // Change reasoning to another supported value; model and permission + // round trips preserve the user's effective defaults without a prompt. + let value = if category == "thought_level" { + option["options"] + .as_array() + .unwrap() + .iter() + .filter_map(|choice| choice["value"].as_str()) + .find(|value| *value != current) + .unwrap_or(current) + } else { + current + }; + session.send(AgentCommand::SetConfigOption { + token: category.into(), + session_id: id.clone(), + config_id: config_id.into(), + value: value.into(), + }); + let updated = session.wait( + "config acknowledgement", + None, + &mut String::new(), + |event| match event { + AgentEvent::SessionConfigured { + token, + config_options, + .. + } if token == category => Some(serde_json::to_value(config_options).unwrap()), + _ => None, + }, + ); + let actual = updated + .as_array() + .unwrap() + .iter() + .find(|o| o["id"] == config_id) + .unwrap(); + assert_eq!(actual["currentValue"], value); + } + session.handle.close(); +} diff --git a/rust/lithe-core/Cargo.toml b/rust/lithe-core/Cargo.toml index 53800e763..2c24d09a2 100644 --- a/rust/lithe-core/Cargo.toml +++ b/rust/lithe-core/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["rlib", "staticlib", "cdylib"] [dependencies] lithe-git-host = { path = "../lithe-git-host" } +lithe-agent-host = { path = "../lithe-agent-host" } ammonia = "4.1.4" base64 = "0.22" comrak = { version = "0.54.0", default-features = false, features = ["shortcodes"] } diff --git a/rust/lithe-core/include/lithe_core.h b/rust/lithe-core/include/lithe_core.h index d5b42e60f..a3f8fcccf 100644 --- a/rust/lithe-core/include/lithe_core.h +++ b/rust/lithe-core/include/lithe_core.h @@ -14,6 +14,9 @@ char *lithe_core_execute_json_with_events(const char *request, void (*callback)( char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); +void *lithe_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); +int32_t lithe_agent_send_json(void *handle, const char *command); +void lithe_agent_close(void *handle); #ifdef __cplusplus } diff --git a/rust/lithe-core/src/agent/mod.rs b/rust/lithe-core/src/agent/mod.rs new file mode 100644 index 000000000..1ccba7d19 --- /dev/null +++ b/rust/lithe-core/src/agent/mod.rs @@ -0,0 +1,148 @@ +//! Agent adapter management commands backed by `lithe-agent-host`. +//! +//! Detection and installs use the user's own Node.js and npm; Core only maps +//! requests, cooperative cancellation, and failures onto the shared envelope. + +use std::path::PathBuf; + +use serde::Deserialize; +use serde_json::Value; + +use crate::protocol::{cancellation, CoreError, ErrorCode}; +use lithe_agent_host::install::{self, ManagementError}; + +/// Payload of `agent.status`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentStatusRequest { + /// Platform-owned directory holding Lithe-managed adapter installs. + data_directory: PathBuf, +} + +/// Payload of `agent.install`, `agent.uninstall`, and `agent.installCli`. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AgentInstallRequest { + data_directory: PathBuf, + /// ACP registry id of a catalog agent, e.g. `codex-acp`. + agent_id: String, +} + +fn cancelled() -> bool { + cancellation::check().is_err() +} + +fn absolute(directory: &PathBuf) -> Result<(), CoreError> { + if directory.is_absolute() { + Ok(()) + } else { + Err(CoreError::new( + ErrorCode::InvalidRequest, + "The agent data directory must be absolute", + )) + } +} + +/// Detect Node.js and npm and report every catalog agent's install state. +pub(crate) fn status(request: AgentStatusRequest) -> Result { + absolute(&request.data_directory)?; + let status = install::status(&request.data_directory, &cancelled); + cancellation::check()?; + serde_json::to_value(status) + .map_err(|error| CoreError::new(ErrorCode::Unknown, error.to_string())) +} + +/// Install the pinned adapter version with the user's npm. +pub(crate) fn install(request: AgentInstallRequest) -> Result { + absolute(&request.data_directory)?; + install::install_with_progress( + &request.data_directory, + &request.agent_id, + &cancelled, + &emit_progress, + ) + .map(|version| serde_json::json!({ "agentId": request.agent_id, "installedVersion": version })) + .map_err(core_error) +} + +/// Update through the CLI's installation owner and return its verified outcome. +pub(crate) fn install_cli(request: AgentInstallRequest) -> Result { + absolute(&request.data_directory)?; + install::install_cli_with_progress(&request.agent_id, &cancelled, &emit_progress) + .map(|result| { + serde_json::json!({ "agentId": request.agent_id, + "cliVersion": result.cli_version, "updaterWarning": result.updater_warning }) + }) + .map_err(core_error) +} + +/// Remove a Lithe-managed adapter install. +pub(crate) fn uninstall(request: AgentInstallRequest) -> Result { + absolute(&request.data_directory)?; + install::uninstall(&request.data_directory, &request.agent_id) + .map(|()| serde_json::json!({ "agentId": request.agent_id })) + .map_err(core_error) +} + +fn core_error(error: ManagementError) -> CoreError { + // Prefer the envelope's own cancellation state so a deadline reports + // `timed_out` and a user cancel reports `cancelled`. + if matches!(error, ManagementError::Cancelled) { + if let Err(error) = cancellation::check() { + return error; + } + } + let code = match error { + ManagementError::UnknownAgent(_) => ErrorCode::InvalidRequest, + ManagementError::RuntimeMissing(_) => ErrorCode::RuntimeMissing, + ManagementError::Failed(_) => ErrorCode::ProcessFailed, + ManagementError::Cancelled => ErrorCode::Cancelled, + ManagementError::TimedOut => ErrorCode::TimedOut, + }; + CoreError::new(code, error.message()) +} + +/// Request-scoped install event destination. No callbacks outlive a synchronous request. +#[derive(Clone)] +struct ProgressContext { + sink: std::sync::Arc, + operation_id: Option, +} +thread_local! { + static PROGRESS: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +struct ProgressScope(Option); +impl Drop for ProgressScope { + fn drop(&mut self) { + PROGRESS.with(|p| *p.borrow_mut() = self.0.take()); + } +} + +/// Shares the existing event ABI with npm management, preserving nested request scopes. +pub(crate) fn with_progress_sink( + request: &str, + sink: std::sync::Arc, + work: impl FnOnce() -> T, +) -> T { + let request: Value = serde_json::from_str(request).unwrap_or(Value::Null); + let operation_id = request + .get("operationId") + .or_else(|| request.get("id")) + .and_then(Value::as_str) + .map(str::to_owned); + let _scope = + ProgressScope(PROGRESS.with(|p| p.replace(Some(ProgressContext { sink, operation_id })))); + work() +} + +fn emit_progress(progress: install::InstallProgress) { + // Temporarily revoke the scope while delivering: a callback may re-enter Core. + let context = PROGRESS.with(|p| p.take()); + let _restore = ProgressScope(context.clone()); + if let Some(context) = context { + (context.sink)(&serde_json::json!({ + "kind": "agentInstallProgress", "operationId": context.operation_id, "progress": progress + }).to_string()); + } +} diff --git a/rust/lithe-core/src/ai/agent_configuration.rs b/rust/lithe-core/src/ai/agent_configuration.rs new file mode 100644 index 000000000..2294d9369 --- /dev/null +++ b/rust/lithe-core/src/ai/agent_configuration.rs @@ -0,0 +1,37 @@ +//! Bounded provider metadata parsing for Agent settings; hosts retain credentials and persistence. + +use crate::protocol::{CoreError, ErrorCode}; +use serde::Deserialize; +use serde_json::Value; +use std::collections::BTreeMap; + +/// Parses pasted configuration without consulting files or the host environment. +/// Responses and failures never include authentication material or raw input. +pub fn parse_provider_configuration(payload: Value) -> Result { + #[derive(Deserialize)] + struct Request { + /// CLI configuration format, either `codex` or `claude`. + source: String, + /// UTF-8 TOML or JSON, limited to 64 KiB before parsing. + configuration: String, + } + let invalid = || { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid Agent provider configuration", + ) + }; + let request: Request = serde_json::from_value(payload).map_err(|_| invalid())?; + if request.configuration.len() > 64 * 1024 || request.configuration.trim().is_empty() { + return Err(invalid()); + } + let environment = BTreeMap::new(); + let detected = match request.source.as_str() { + "codex" => super::parse_codex(&request.configuration, "{}", &environment), + "claude" => super::parse_claude(&request.configuration, "", "", &environment), + _ => return Err(invalid()), + } + .map_err(|_| invalid())?; + // Serialize metadata only; even the credential-presence flag stays host-owned. + serde_json::to_value(detected.provider).map_err(|_| invalid()) +} diff --git a/rust/lithe-core/src/ai/mod.rs b/rust/lithe-core/src/ai/mod.rs index be2cdce4b..91b5d1024 100644 --- a/rust/lithe-core/src/ai/mod.rs +++ b/rust/lithe-core/src/ai/mod.rs @@ -1,8 +1,10 @@ //! Portable AI commit configuration parsing and HTTP request planning; hosts own I/O and secrets. +mod agent_configuration; mod configuration; mod generation; +pub use agent_configuration::parse_provider_configuration; pub use configuration::{parse_claude, parse_codex, DetectedConfiguration}; pub use generation::{ decode_message, plan_commit, ChatTokenLimitField, CommitFile, CommitOptions, CommitRequestPlan, diff --git a/rust/lithe-core/src/ai/tests.rs b/rust/lithe-core/src/ai/tests.rs index f968f96ae..a7b5f3eea 100644 --- a/rust/lithe-core/src/ai/tests.rs +++ b/rust/lithe-core/src/ai/tests.rs @@ -4,6 +4,50 @@ use super::*; use serde_json::{json, Value}; use std::collections::BTreeMap; +#[test] +fn agent_provider_configuration_matches_fixture_without_credentials() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/provider-configuration-v1.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let response: Value = serde_json::from_str(&crate::execute_json( + &json!({ + "id": "provider-fixture", "command": "agent.parseProviderConfiguration", + "payload": {"source": case["source"], "configuration": case["configuration"]} + }) + .to_string(), + )) + .unwrap(); + assert_eq!(response["ok"], true); + assert_eq!(response["id"], "provider-fixture"); + let parsed = &response["data"]; + for (key, value) in case["expected"].as_object().unwrap() { + assert_eq!(&parsed[key], value); + } + assert!(!parsed.to_string().contains("fixture-secret")); + assert!(parsed.get("credential").is_none()); + assert!(parsed.get("hasCredential").is_none()); + } +} + +#[test] +fn agent_provider_configuration_errors_are_bounded_and_redacted() { + for payload in [ + json!({"source":"codex", "configuration":"fixture-secret = ["}), + json!({"source":"claude", "configuration":"[]"}), + json!({"source":"unknown", "configuration":"{}"}), + json!({"source":"codex", "configuration":"x".repeat(65 * 1024)}), + json!({"source":"codex", "configuration": null}), + ] { + let error = parse_provider_configuration(payload).unwrap_err(); + let value = serde_json::to_string(&error).unwrap(); + assert!(value.contains("invalid_request")); + assert!(!value.contains("fixture-secret")); + assert!(!value.contains("details")); + } +} + fn fixture() -> (Provider, CommitOptions, Vec, Value) { let value: Value = serde_json::from_str(include_str!( "../../../../shared/fixtures/ai/commit-generation-v1.json" diff --git a/rust/lithe-core/src/git/mod.rs b/rust/lithe-core/src/git/mod.rs index b5f75c456..c3a9270f4 100644 --- a/rust/lithe-core/src/git/mod.rs +++ b/rust/lithe-core/src/git/mod.rs @@ -1433,6 +1433,7 @@ fn capture_git_process( environment: &[(String, String)], visible: bool, ) -> Result { + validate_windows_git_path(Path::new(root)).map_err(git_path_error)?; crate::protocol::cancellation::check()?; let mut process = git_process(); let version = lithe_git_host::configuration::version(process.get_program(), || { @@ -3047,34 +3048,119 @@ pub fn blame(request: GitBlameRequest) -> Result { Ok(GitBlameResponse { lines }) } -/// Removes the Windows verbatim prefix that `Path::canonicalize` adds. -/// -/// Canonical Windows paths come back as `\\?\C:\...` or `\\?\UNC\server\share`. -/// Once a consumer normalizes separators, both forms turn into `//?/...`, -/// which no longer resolves to the original location. Repository roots cross -/// the platform boundary as identifiers and are reused as Git working -/// directories, so they must stay in plain native form. Non-Windows paths are -/// returned unchanged. -pub(crate) fn simplified_canonical_path(path: PathBuf) -> PathBuf { - let simplified = { - let text = path.to_string_lossy(); - if let Some(network_path) = text.strip_prefix(r"\\?\UNC\") { - Some(PathBuf::from(format!(r"\\{network_path}"))) - } else { - text.strip_prefix(r"\\?\").map(PathBuf::from) +/// Rejects Windows names that cannot round-trip through ordinary path identifiers. +/// POSIX names keep their native meaning; Windows validates relative paths too. +fn validate_windows_git_path(path: &Path) -> std::io::Result<()> { + let text = path.to_string_lossy().replace('\\', "/"); + let windows_path = cfg!(windows) + || text.starts_with("//") + || (text.as_bytes().get(1) == Some(&b':') && text.as_bytes()[0].is_ascii_alphabetic()); + if !windows_path { + return Ok(()); + } + let verbatim = text.strip_prefix("//?/"); + let ordinary = match verbatim { + Some(rest) + if rest + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("UNC/")) => + { + format!("//{}", &rest[4..]) } + Some(rest) => rest.to_string(), + None => text.clone(), }; - simplified.unwrap_or(path) + let drive = ordinary.as_bytes().get(1..3) == Some(b":/") + && ordinary.as_bytes()[0].is_ascii_alphabetic(); + let network = ordinary.starts_with("//"); + // Server/share names identify the UNC route, not local file components. + let components: Vec<_> = ordinary + .split('/') + .filter(|part| !part.is_empty()) + .collect(); + let skip = if network { + 2 + } else if drive { + 1 + } else { + 0 + }; + let invalid_component = components.iter().skip(skip).any(|part| { + if matches!(*part, "." | "..") { + return verbatim.is_some(); + } + let basename = part + .split('.') + .next() + .unwrap_or_default() + .to_ascii_uppercase(); + let reserved = matches!(basename.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || basename + .strip_prefix("COM") + .or_else(|| basename.strip_prefix("LPT")) + .is_some_and(|number| { + matches!( + number, + "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" | "¹" | "²" | "³" + ) + }); + part.ends_with('.') || part.ends_with(' ') || reserved + }); + if text.starts_with("//./") + || (verbatim.is_some() && !drive && !(network && components.len() >= 2)) + || invalid_component + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Git does not support Windows paths requiring verbatim semantics (trailing dots/spaces, reserved names or device namespaces)", + )); + } + Ok(()) +} + +/// Removes a Windows verbatim prefix only for paths safe in ordinary native form. +/// Canonical roots cross the UI boundary and return as Git working directories; +/// unsupported names fail explicitly instead of silently accessing a sibling. +pub(crate) fn simplified_canonical_path(path: PathBuf) -> std::io::Result { + validate_windows_git_path(&path)?; + let text = path.to_string_lossy(); + let normalized = text.replace('\\', "/"); + if normalized + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("//?/UNC/")) + { + Ok(PathBuf::from(format!(r"\\{}", &text[8..]))) + } else if normalized.starts_with("//?/") { + Ok(PathBuf::from(&text[4..])) + } else { + Ok(path) + } } -/// Canonicalizes `path` and strips the Windows verbatim prefix from the result. +/// Validates before filesystem I/O and again after canonicalization (symlinks). fn canonicalize_simplified(path: &Path) -> std::io::Result { - path.canonicalize().map(simplified_canonical_path) + validate_windows_git_path(path)?; + path.canonicalize().and_then(simplified_canonical_path) +} + +/// Missing worktrees may still be listed, but invalid path identities never fall back. +fn canonicalize_or_original(path: &Path) -> std::io::Result { + match canonicalize_simplified(path) { + Err(error) if error.kind() != std::io::ErrorKind::InvalidInput => Ok(path.to_path_buf()), + result => result, + } +} + +fn git_path_error(error: std::io::Error) -> CoreError { + if error.kind() == std::io::ErrorKind::InvalidInput { + CoreError::new(ErrorCode::InvalidRequest, error.to_string()) + } else { + CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist") + } } fn validate_root(raw_root: &str) -> Result { - let root = canonicalize_simplified(Path::new(raw_root)) - .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + let root = canonicalize_simplified(Path::new(raw_root)).map_err(git_path_error)?; if !root.is_dir() { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, @@ -3085,6 +3171,9 @@ fn validate_root(raw_root: &str) -> Result { } fn repository_scan_error(error: std::io::Error) -> CoreError { + if error.kind() == std::io::ErrorKind::InvalidInput { + return git_path_error(error); + } CoreError::new( ErrorCode::Unknown, "Could not complete workspace repository discovery", @@ -3101,12 +3190,11 @@ fn discover_containing_repository(root: &Path) -> Result, CoreEr if output.exit_code != 0 { return Ok(None); } - let repository_root = output.stdout.trim(); + let repository_root = output.stdout.trim_end_matches(['\r', '\n']); if repository_root.is_empty() { return Ok(None); } - let path = canonicalize_simplified(Path::new(repository_root)) - .unwrap_or_else(|_| PathBuf::from(repository_root)); + let path = canonicalize_or_original(Path::new(repository_root)).map_err(git_path_error)?; Ok(Some(path)) } @@ -3468,13 +3556,14 @@ fn git_resolved_path(root: &str, arguments: &[&str], label: &str) -> Result Result, CoreError> { .with_details(response.output), ); } - let current_root = - canonicalize_simplified(&repository_root(root)?).unwrap_or_else(|_| PathBuf::from(root)); + let current_root = canonicalize_or_original(&repository_root(root)?).map_err(git_path_error)?; let mut records = Vec::new(); let mut fields = Vec::new(); for field in response.stdout.split('\0') { @@ -4963,8 +5051,7 @@ fn parse_worktree_record( let lock = value_after_marker("locked"); let prunable = value_after_marker("prunable"); let reported_path = PathBuf::from(path); - let normalized_path = - canonicalize_simplified(&reported_path).unwrap_or_else(|_| reported_path.clone()); + let normalized_path = canonicalize_or_original(&reported_path).map_err(git_path_error)?; Ok(GitWorktreeResponse { path: normalized_path.to_string_lossy().to_string(), head, @@ -6182,8 +6269,7 @@ fn parse_diff(patch: &str) -> (Vec, Vec pub fn watch_context( request: GitWatchContextRequest, ) -> Result, CoreError> { - let root = canonicalize_simplified(Path::new(&request.root)) - .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; if !root.is_dir() { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, @@ -6217,10 +6303,13 @@ fn canonical_git_output(output: std::process::Output, label: &str) -> Result bool { /// Returns the normalized repository status and branch context. pub fn status(request: GitStatusRequest) -> Result { - let root = canonicalize_simplified(Path::new(&request.root)) - .map_err(|_| CoreError::new(ErrorCode::WorkspaceNotFound, "Workspace does not exist"))?; + let root = canonicalize_simplified(Path::new(&request.root)).map_err(git_path_error)?; if !root.is_dir() { return Err(CoreError::new( ErrorCode::WorkspaceNotFound, @@ -6371,9 +6459,9 @@ pub fn status(request: GitStatusRequest) -> Result }); } let repository_root_text = String::from_utf8_lossy(&repository_root_output.stdout); - let repository_root_path = PathBuf::from(repository_root_text.trim()); + let repository_root_path = PathBuf::from(repository_root_text.trim_end_matches(['\r', '\n'])); let repository_root = - canonicalize_simplified(&repository_root_path).unwrap_or(repository_root_path); + canonicalize_or_original(&repository_root_path).map_err(git_path_error)?; let branch = run_git(&repository_root, &["branch", "--show-current"]) .ok() .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) @@ -6576,19 +6664,42 @@ mod tests { // separators, which would turn them into `//?/C:/...` and break every // later lookup of the discovered repository root. assert_eq!( - simplified_canonical_path(PathBuf::from(r"\\?\C:\work\repo")), + simplified_canonical_path(PathBuf::from(r"\\?\C:\work\repo")).unwrap(), PathBuf::from(r"C:\work\repo") ); assert_eq!( - simplified_canonical_path(PathBuf::from(r"\\?\UNC\server\share\repo")), + simplified_canonical_path(PathBuf::from(r"\\?\UNC\server\share\repo")).unwrap(), PathBuf::from(r"\\server\share\repo") ); assert_eq!( - simplified_canonical_path(PathBuf::from("/work/repo")), + simplified_canonical_path(PathBuf::from("/work/repo")).unwrap(), PathBuf::from("/work/repo") ); } + #[test] + fn windows_git_paths_follow_shared_identity_policy() { + let cases: Value = serde_json::from_str(include_str!( + "../../../../shared/fixtures/git/windows-paths.json" + )) + .unwrap(); + for case in cases["accepted"].as_array().unwrap() { + let path = + simplified_canonical_path(PathBuf::from(case["input"].as_str().unwrap())).unwrap(); + assert_eq!( + path.to_string_lossy().replace('\\', "/"), + case["normalized"].as_str().unwrap() + ); + } + for path in cases["rejected"].as_array().unwrap() { + let path = PathBuf::from(path.as_str().unwrap()); + let error = simplified_canonical_path(path.clone()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput, "{path:?}"); + // A missing/stale worktree must not bypass the same identity check. + assert!(super::canonicalize_or_original(&path).is_err()); + } + } + #[test] fn repository_root_discovery_does_not_acquire_a_write_lease() { struct Repository(std::path::PathBuf); diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index 61773750b..d8de43f34 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -2,6 +2,7 @@ use std::path::Path; +mod agent; pub mod ai; mod community; mod debug; @@ -45,16 +46,18 @@ pub fn jdt_workspace_key(workspace_root: &Path, workspace_fingerprint: Option<&s #[cfg(test)] mod tests; -/// Executes a request with ordered, sanitized Git process events. The observer -/// is scoped to this call; callbacks complete before the final JSON is returned. +/// Executes a request with sanitized Git process events and Agent install progress. +/// The observer is scoped to this call; callbacks complete before the final JSON returns. pub fn execute_json_with_events( request: &str, sink: std::sync::Arc, ) -> String { - git::execution_events::with_sink(sink, || { - let response = execute_json(request); - git::execution_events::request_finished(&response); - response + agent::with_progress_sink(request, sink.clone(), || { + git::execution_events::with_sink(sink, || { + let response = execute_json(request); + git::execution_events::request_finished(&response); + response + }) }) } diff --git a/rust/lithe-core/src/protocol/command.rs b/rust/lithe-core/src/protocol/command.rs index f76781665..e217a2c91 100644 --- a/rust/lithe-core/src/protocol/command.rs +++ b/rust/lithe-core/src/protocol/command.rs @@ -34,6 +34,16 @@ pub struct CoreRequest { pub enum CoreCommand { /// Reports the Core and protocol versions (`core.ping`). Ping, + /// Detects Node.js and npm and lists catalog agent installs (`agent.status`). + AgentStatus, + /// Installs a catalog ACP adapter with the user's npm (`agent.install`). + AgentInstall, + /// Removes a Lithe-managed ACP adapter install (`agent.uninstall`). + AgentUninstall, + /// Installs or updates an agent's CLI through its installation owner (`agent.installCli`). + AgentInstallCli, + /// Parses credential-free Agent provider metadata (`agent.parseProviderConfiguration`). + AgentParseProviderConfiguration, /// Starts a Discourse user API key authorization (`community.discourse.auth.begin`). CommunityDiscourseAuthBegin, /// Decrypts and verifies a Discourse authorization callback (`community.discourse.auth.complete`). @@ -314,6 +324,11 @@ impl CoreCommand { pub fn parse(value: &str) -> Option { match value { "core.ping" => Some(Self::Ping), + "agent.status" => Some(Self::AgentStatus), + "agent.install" => Some(Self::AgentInstall), + "agent.uninstall" => Some(Self::AgentUninstall), + "agent.installCli" => Some(Self::AgentInstallCli), + "agent.parseProviderConfiguration" => Some(Self::AgentParseProviderConfiguration), "community.discourse.auth.begin" => Some(Self::CommunityDiscourseAuthBegin), "community.discourse.auth.complete" => Some(Self::CommunityDiscourseAuthComplete), "community.discourse.topics" => Some(Self::CommunityDiscourseTopics), diff --git a/rust/lithe-core/src/runtime/dispatcher.rs b/rust/lithe-core/src/runtime/dispatcher.rs index b23d5a19b..a386a2a2b 100644 --- a/rust/lithe-core/src/runtime/dispatcher.rs +++ b/rust/lithe-core/src/runtime/dispatcher.rs @@ -50,6 +50,27 @@ pub fn execute_json(request: &str) -> String { }) } +/// Decode an agent management payload and run `operation` on it. +fn agent_response( + id: Option, + payload: serde_json::Value, + operation: fn(T) -> Result, +) -> CoreResponse { + match serde_json::from_value::(payload) + .map_err(|error| { + CoreError::new( + ErrorCode::InvalidRequest, + "Invalid agent management request", + ) + .with_details(error.to_string()) + }) + .and_then(operation) + { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } +} + fn execute(request: &str) -> CoreResponse { let parsed: CoreRequest = match serde_json::from_str(request) { Ok(request) => request, @@ -92,6 +113,18 @@ fn execute(request: &str) -> CoreResponse { "coreVersion": env!("CARGO_PKG_VERSION") }), ), + CoreCommand::AgentStatus => agent_response(id, parsed.payload, crate::agent::status), + CoreCommand::AgentParseProviderConfiguration => { + match crate::ai::parse_provider_configuration(parsed.payload) { + Ok(data) => CoreResponse::success(id, data), + Err(error) => CoreResponse::failure(id, error), + } + } + CoreCommand::AgentInstall => agent_response(id, parsed.payload, crate::agent::install), + CoreCommand::AgentUninstall => agent_response(id, parsed.payload, crate::agent::uninstall), + CoreCommand::AgentInstallCli => { + agent_response(id, parsed.payload, crate::agent::install_cli) + } CoreCommand::CommunityDiscourseAuthBegin => { match serde_json::from_value::(parsed.payload) .map_err(|error| { diff --git a/rust/lithe-core/src/runtime/ffi.rs b/rust/lithe-core/src/runtime/ffi.rs index 18c81e088..7530abe92 100644 --- a/rust/lithe-core/src/runtime/ffi.rs +++ b/rust/lithe-core/src/runtime/ffi.rs @@ -3,6 +3,19 @@ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +struct AgentFFIHandle { + handle: lithe_agent_host::AgentHandle, + callback: Arc< + Mutex< + Option<( + unsafe extern "C" fn(*const c_char, *mut std::ffi::c_void), + usize, + )>, + >, + >, +} /// Returns a pointer to the static, NUL-terminated Core ABI version. /// @@ -96,7 +109,7 @@ fn response_pointer(value: &str) -> *mut c_char { .into_raw() } -/// Executes JSON with request-scoped Git diagnostic callbacks. Each event string +/// Executes JSON with request-scoped Git diagnostics and Agent install progress. Each event string /// is borrowed only during the callback; the response uses the normal ownership. /// /// # Safety @@ -138,3 +151,97 @@ pub unsafe extern "C" fn lithe_core_git_askpass(prompt: *const c_char) -> i32 { } crate::git_askpass_main(&CStr::from_ptr(prompt).to_string_lossy()) } + +/// Starts one ACP agent connection and delivers UTF-8 JSON events on a worker thread. +/// +/// The returned opaque handle must be closed once with [`lithe_agent_close`]. +/// +/// # Safety +/// +/// `configuration` must be readable NUL-terminated JSON. `callback` must not +/// unwind or retain its borrowed event pointer. `context` must remain valid +/// until `lithe_agent_close` returns. Other handle calls must not race close. +#[no_mangle] +pub unsafe extern "C" fn lithe_agent_open_json( + configuration: *const c_char, + callback: Option, + context: *mut std::ffi::c_void, +) -> *mut std::ffi::c_void { + let Some(callback) = callback else { + return std::ptr::null_mut(); + }; + if configuration.is_null() { + return std::ptr::null_mut(); + } + let Ok(launch) = serde_json::from_slice::( + CStr::from_ptr(configuration).to_bytes(), + ) else { + return std::ptr::null_mut(); + }; + let callback_state = Arc::new(Mutex::new(Some((callback, context as usize)))); + let state = callback_state.clone(); + let emit = Arc::new(move |event: lithe_agent_host::AgentEvent| { + if let Ok(json) = serde_json::to_string(&event) { + if let Ok(json) = CString::new(json) { + // Holding the lock lets close revoke the callback only after an + // in-flight call returns, so Swift can then release context. + if let Ok(state) = state.lock() { + if let Some((callback, context)) = *state { + callback(json.as_ptr(), context as *mut std::ffi::c_void); + } + } + } + } + }); + match lithe_agent_host::AgentHandle::open(launch, emit) { + Ok(handle) => Box::into_raw(Box::new(AgentFFIHandle { + handle, + callback: callback_state, + })) + .cast(), + Err(_) => std::ptr::null_mut(), + } +} + +/// Queues one UTF-8 JSON command on an ACP connection; returns 1 when accepted. +/// +/// Commands follow `shared/fixtures/agent/acp-events-v1.json`. Results arrive +/// asynchronously as events; 0 means the JSON was invalid, the permission +/// request is gone, or the connection has stopped. +/// +/// # Safety +/// `handle` must be an open handle from `lithe_agent_open_json`; `command` must +/// be a readable NUL-terminated string for this call. +#[no_mangle] +pub unsafe extern "C" fn lithe_agent_send_json( + handle: *mut std::ffi::c_void, + command: *const c_char, +) -> i32 { + if handle.is_null() || command.is_null() { + return 0; + } + let handle = &*(handle as *mut AgentFFIHandle); + let Ok(command) = serde_json::from_slice::( + CStr::from_ptr(command).to_bytes(), + ) else { + return 0; + }; + handle.handle.send(command).is_ok() as i32 +} + +/// Revokes callbacks, stops the agent tree, and frees an ACP handle. +/// +/// # Safety +/// `handle` must be null or an open handle from `lithe_agent_open_json`. Calls +/// using the same handle must finish before close; the handle is invalid after. +#[no_mangle] +pub unsafe extern "C" fn lithe_agent_close(handle: *mut std::ffi::c_void) { + if handle.is_null() { + return; + } + let handle = Box::from_raw(handle as *mut AgentFFIHandle); + if let Ok(mut callback) = handle.callback.lock() { + callback.take(); + } + handle.handle.close(); +} diff --git a/rust/lithe-core/src/tests/agent.rs b/rust/lithe-core/src/tests/agent.rs new file mode 100644 index 000000000..e0c3b68bb --- /dev/null +++ b/rust/lithe-core/src/tests/agent.rs @@ -0,0 +1,146 @@ +use crate::execute_json; +use serde_json::{json, Value}; + +fn run(command: &str, payload: Value) -> Value { + let request = json!({ "id": "agent-test", "command": command, "payload": payload }); + serde_json::from_str(&execute_json(&request.to_string())).expect("core response JSON") +} + +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../../shared/fixtures/agent/agent-management-v1.json" + )) + .expect("agent management fixture") +} + +#[test] +fn fixture_requests_are_accepted_by_the_dispatcher() { + for name in ["status", "install", "uninstall", "installCli"] { + let command = fixture()["requests"][name]["command"] + .as_str() + .unwrap() + .to_owned(); + assert!( + crate::protocol::CoreCommand::parse(&command).is_some(), + "{command}" + ); + } +} + +#[test] +fn status_reports_every_catalog_agent_in_the_fixture_shape() { + let data = std::env::temp_dir().join(format!("lithe-core-agent-{}", std::process::id())); + let response = run("agent.status", json!({ "dataDirectory": data })); + assert_eq!(response["ok"], true, "{response}"); + let expected = &fixture()["responses"]["status"]; + let agents = response["data"]["agents"].as_array().expect("agents"); + let ids: Vec<&str> = agents + .iter() + .filter_map(|agent| agent["id"].as_str()) + .collect(); + assert_eq!(ids, ["codex-acp", "claude-acp"]); + // Detected values depend on the machine; the keys must match the contract. + let keys = |value: &Value| { + let mut keys: Vec = value.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + keys + }; + assert_eq!(keys(&agents[0]), keys(&expected["agents"][0])); + assert_eq!(keys(&agents[0]["cli"]), keys(&expected["agents"][0]["cli"])); + assert_eq!( + keys(&agents[0]["cli"]["installation"]), + keys(&expected["agents"][0]["cli"]["installation"]) + ); + assert_eq!( + keys(&response["data"]["environment"]), + keys(&expected["environment"]) + ); + assert_eq!(agents[0]["installedVersion"], Value::Null); +} + +#[test] +fn invalid_management_requests_fail_with_stable_codes() { + let data = std::env::temp_dir(); + let unknown = run( + "agent.install", + json!({ "dataDirectory": data, "agentId": "unknown" }), + ); + assert_eq!(unknown["error"]["code"], "invalid_request", "{unknown}"); + let relative = run("agent.status", json!({ "dataDirectory": "relative/path" })); + assert_eq!(relative["error"]["code"], "invalid_request", "{relative}"); + let no_cli = run( + "agent.installCli", + json!({ "dataDirectory": data, "agentId": "unknown" }), + ); + assert_eq!(no_cli["error"]["code"], "invalid_request", "{no_cli}"); + let missing = run("agent.uninstall", json!({ "dataDirectory": data })); + assert_eq!(missing["error"]["code"], "invalid_request", "{missing}"); + let removed = run( + "agent.uninstall", + json!({ "dataDirectory": data.join("lithe-missing-agents"), "agentId": "codex-acp" }), + ); + assert_eq!( + removed["ok"], true, + "removing a missing install succeeds: {removed}" + ); +} + +#[test] +fn install_progress_is_request_scoped_and_uses_the_operation_identifier() { + use std::sync::{Arc, Mutex}; + let captured = Arc::new(Mutex::new(Vec::::new())); + let capture = captured.clone(); + let request = json!({"command":"agent.installCli", "operationId":"progress-fixture", + "payload":{"dataDirectory":std::env::temp_dir(), "agentId":"unknown"}}); + let result: Value = serde_json::from_str(&crate::execute_json_with_events( + &request.to_string(), + Arc::new(move |event| { + capture + .lock() + .unwrap() + .push(serde_json::from_str(event).unwrap()); + }), + )) + .unwrap(); + assert_eq!(result["ok"], false); + let events = captured.lock().unwrap(); + let event = events + .iter() + .find(|e| e["kind"] == "agentInstallProgress") + .unwrap(); + assert_eq!(event["operationId"], "progress-fixture"); + assert_eq!(event["progress"]["downloadedBytes"], 0); + assert_eq!( + event["progress"], + fixture()["events"]["preparing"]["progress"] + ); + let count = events.len(); + drop(events); + crate::execute_json(&request.to_string()); + assert_eq!( + captured.lock().unwrap().len(), + count, + "the callback must not escape its request" + ); +} + +#[test] +fn cli_update_outcome_fixture_preserves_verified_version_and_warning() { + use lithe_agent_host::install::CliUpdateResult; + for (name, version, warning) in [ + ("installCli", "0.156.1", None), + ( + "installCliRecovered", + "0.157.1", + Some("Download failed; retry completed"), + ), + ] { + let result = CliUpdateResult { + cli_version: version.into(), + updater_warning: warning.map(str::to_owned), + }; + let mut actual = serde_json::to_value(result).unwrap(); + actual["agentId"] = json!("codex-acp"); + assert_eq!(actual, fixture()["responses"][name]); + } +} diff --git a/rust/lithe-core/src/tests/git.rs b/rust/lithe-core/src/tests/git.rs index ec6d9442d..d822bb262 100644 --- a/rust/lithe-core/src/tests/git.rs +++ b/rust/lithe-core/src/tests/git.rs @@ -47,6 +47,7 @@ fn reported_repository_path(path: &Path) -> String { path.canonicalize() .expect("discovered repository should exist"), ) + .expect("repository path should be supported") .to_string_lossy() .replace('\\', "/") } @@ -1512,6 +1513,7 @@ fn git_worktrees_lists_primary_linked_and_locked_metadata() { .canonicalize() .expect("linked worktree should canonicalize") ) + .expect("worktree path should be supported") .to_string_lossy() .as_ref() ); diff --git a/rust/lithe-core/src/tests/mod.rs b/rust/lithe-core/src/tests/mod.rs index d94371f18..1bfe83350 100644 --- a/rust/lithe-core/src/tests/mod.rs +++ b/rust/lithe-core/src/tests/mod.rs @@ -1,3 +1,4 @@ +mod agent; mod detectors; mod git; mod git_fetch; diff --git a/rust/lithe-core/tests/git_path_roundtrip.rs b/rust/lithe-core/tests/git_path_roundtrip.rs new file mode 100644 index 000000000..002f8b6a0 --- /dev/null +++ b/rust/lithe-core/tests/git_path_roundtrip.rs @@ -0,0 +1,240 @@ +//! Native repository paths must survive the actual Windows frontend normalizer. + +use lithe_core::execute_json; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + +struct Fixture(PathBuf); + +impl Fixture { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "lithe-path-roundtrip-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&root).unwrap(); + Self(root.canonicalize().unwrap()) + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + // Canonical Windows paths keep the prefix for cleanup of dot/space fixtures. + let _ = fs::remove_dir_all(&self.0); + } +} + +fn run(command: &mut Command) -> String { + let deadline = Instant::now() + Duration::from_secs(5); + let result = lithe_git_host::run( + command, + None, + || Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + assert!(result.failure.is_none(), "{:?}", result.failure); + assert!( + result.status.is_some_and(|status| status.success()), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + String::from_utf8(result.stdout) + .unwrap() + .trim_end_matches(['\r', '\n']) + .to_owned() +} + +fn git(root: &Path, arguments: &[&str]) -> String { + run(Command::new("git") + .current_dir(root) + .args(["-c", "core.longpaths=true", "-c", "commit.gpgsign=false"]) + .args(arguments)) +} + +fn request(command: &str, root: &str) -> Value { + serde_json::from_str(&execute_json( + &json!({ + "command": command, + "timeoutMilliseconds": 5000, + "payload": { "root": root } + }) + .to_string(), + )) + .unwrap() +} + +fn data(command: &str, root: &str) -> Value { + let result = request(command, root); + assert_eq!(result["ok"], true, "{result}"); + result["data"].clone() +} + +fn frontend_path(path: &str) -> String { + let module = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../windows/tauri/src/features/git/api/git-repository-path.ts"); + // Node is already required by both platform timing harnesses. Import the + // production TypeScript function rather than duplicating it in Rust. + run(Command::new("node").args([ + "--experimental-strip-types", "--input-type=module", "-e", + "import { pathToFileURL } from 'node:url'; const { normalizeRepositoryPath } = await import(pathToFileURL(process.argv[1]).href); console.log(normalizeRepositoryPath(process.argv[2]));", + module.to_str().unwrap(), path, + ])) +} + +fn initialize(root: &Path) -> String { + fs::create_dir_all(root).unwrap(); + git(root, &["init", "-q", "-b", "main"]); + git(root, &["config", "user.email", "fixture@example.invalid"]); + git(root, &["config", "user.name", "Path Fixture"]); + git(root, &["config", "core.longpaths", "true"]); + fs::write(root.join("tracked.txt"), "initial\n").unwrap(); + git(root, &["add", "tracked.txt"]); + git(root, &["commit", "-q", "-m", "path identity fixture"]); + git(root, &["rev-parse", "HEAD"]) +} + +fn assert_roundtrip(root: &Path, commit: &str) -> String { + let discovered = data("git.repositoryRoot", root.to_str().unwrap()); + let normalized = frontend_path(discovered.as_str().unwrap()); + let history = data("git.history", &normalized); + assert!(history["commits"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["hash"] == commit)); + let references = data("git.references", &normalized); + assert!(references["references"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["fullName"] == "refs/heads/main")); + normalized +} + +#[test] +fn git_paths_roundtrip_unicode_spaces_and_linked_worktree() { + let fixture = Fixture::new(); + let root = fixture.0.join("项目 with spaces"); + let first = initialize(&root); + fs::write(root.join("tracked.txt"), "stash contents\n").unwrap(); + git(&root, &["stash", "push", "-q", "-m", "path-stash-marker"]); + let normalized = assert_roundtrip(&root, &first); + let stashes = data("git.stashes", &normalized); + assert!(stashes["stashes"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["message"] + .as_str() + .unwrap() + .contains("path-stash-marker"))); + + let linked = fixture.0.join("linked 工作树"); + // Product consumers pass ordinary paths to native Git, not the verbatim + // prefix used by this fixture for Windows filesystem cleanup. + let linked_argument = frontend_path(linked.to_str().unwrap()); + git( + &root, + &["worktree", "add", "-q", "-b", "linked", &linked_argument], + ); + let linked_normalized = assert_roundtrip(&linked, &first); + let context = data("git.watchContext", &linked_normalized); + assert_ne!(context["gitDirectory"], context["gitCommonDirectory"]); + assert!(Path::new(context["gitDirectory"].as_str().unwrap()) + .join("HEAD") + .is_file()); + assert!(Path::new(context["gitCommonDirectory"].as_str().unwrap()) + .join("refs/heads/main") + .is_file()); + + // A native external commit/checkout must be visible through the same root. + git( + &linked, + &["commit", "--allow-empty", "-q", "-m", "external change"], + ); + let second = git(&linked, &["rev-parse", "HEAD"]); + assert_roundtrip(&linked, &second); + git(&linked, &["checkout", "-q", "--detach", &first]); + let refreshed = data("git.watchContext", &linked_normalized); + let head = + fs::read_to_string(Path::new(refreshed["gitDirectory"].as_str().unwrap()).join("HEAD")) + .unwrap(); + assert_eq!(head.trim(), first); +} + +#[test] +fn git_paths_roundtrip_or_explicitly_reject_a_repository_beyond_max_path() { + let fixture = Fixture::new(); + let source = fixture.0.join("source"); + let commit = initialize(&source); + let mut root = fixture.0.clone(); + while root.as_os_str().len() <= 280 { + root.push("long-path-segment"); + } + fs::create_dir_all(root.parent().unwrap()).unwrap(); + fs::rename(&source, &root).unwrap(); + // Windows can create this directory using verbatim filesystem APIs while + // CreateProcess still rejects it as a working directory (ERROR_DIRECTORY). + // Seed the repository at a short path so this tests Core, not fixture setup. + #[cfg(windows)] + { + let native = root.to_str().unwrap(); + let response = request("git.repositoryRoot", native); + if response["ok"] == false { + for path in [native.to_string(), frontend_path(native)] { + let error = request("git.repositoryRoot", &path); + assert_eq!(error["ok"], false, "{error}"); + assert_eq!(error["error"]["code"], "process_start_failed", "{error}"); + assert_eq!(error["error"]["message"], "Could not start Git", "{error}"); + assert!( + error["error"]["details"] + .as_str() + .unwrap() + .contains("os error 267"), + "{error}" + ); + } + assert!(root.join(".git/HEAD").is_file()); + return; + } + } + assert_roundtrip(&root, &commit); +} + +#[cfg(windows)] +#[test] +fn git_paths_reject_native_dot_and_space_names_without_aliasing_a_sibling() { + let fixture = Fixture::new(); + let ordinary = fixture.0.join("repo"); + initialize(&ordinary); + for name in ["repo.", "repo "] { + let special = fixture.0.join(name); + fs::create_dir(&special).unwrap(); + fs::write(special.join("marker"), name).unwrap(); + for command in [ + "git.repositoryRoot", + "workspace.repositories", + "git.status", + "git.watchContext", + "git.history", + "git.stashes", + ] { + let response = request(command, special.to_str().unwrap()); + assert_eq!(response["ok"], false, "{command}: {response}"); + assert_eq!( + response["error"]["code"], "invalid_request", + "{command}: {response}" + ); + } + assert_eq!(fs::read_to_string(special.join("marker")).unwrap(), name); + assert!(!ordinary.join("marker").exists()); + } +} diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index f8c1608ea..bc1b3e0c1 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -77,13 +77,18 @@ async function readRegistry() { } identifiers.add(resource.id); } - for (const excluded of registry.excludedResources ?? []) { - if (!excluded.id || !excluded.path || !excluded.reason || identifiers.has(excluded.id)) { - throw new Error("Excluded resources need a unique id, path and isolation reason"); + const excludedResources = registry.excludedResources ?? []; + if (!Array.isArray(excludedResources)) throw new Error("Invalid excluded worktree resources"); + for (const resource of excludedResources) { + if (!resource?.id || identifiers.has(resource.id) || resource.reusable !== false + || !Array.isArray(resource.locations) || !resource.locations.length + || !resource.locations.every((location) => typeof location === "string" && location.length > 0) + || !resource.identity || !resource.reason) { + throw new Error(`Invalid excluded worktree resource: ${resource?.id ?? ""}`); } - identifiers.add(excluded.id); + identifiers.add(resource.id); } - return registry.resources; + return { resources: registry.resources, excludedResources }; } function runGit(worktree, argumentsList) { @@ -352,21 +357,19 @@ async function main() { process.stdout.write(`${usage()}\n`); return; } - const resources = await readRegistry(); + const { resources, excludedResources } = await readRegistry(); if (options.list) { for (const resource of resources) process.stdout.write(`${resource.id}\t${resource.path}\n`); return; } if (!options.source) throw new Error(`--source is required\n\n${usage()}`); - const registry = JSON.parse(await fs.readFile(REGISTRY_PATH, "utf8")); - // This route also rejects generated worker packages and mutable installed-plugin state. - for (const id of options.resources) { - const excluded = registry.excludedResources?.find((resource) => resource.id === id); - if (excluded) throw new Error(`Resource ${id} is isolated: ${excluded.reason}`); - } const selected = options.resources.length > 0 ? options.resources.map((identifier) => { + if (excludedResources.some((resource) => resource.id === identifier)) { + const excluded = excludedResources.find((resource) => resource.id === identifier); + throw new Error(`Resource ${identifier} is isolated (${excluded.locations.join(", ")}): ${excluded.reason}; it cannot be reused across worktrees`); + } const resource = resources.find((candidate) => candidate.id === identifier); if (!resource) throw new Error(`Unknown resource: ${identifier}`); return resource; diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 002912afd..1cc8a1cff 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import assert from "node:assert/strict"; +import test from "node:test"; import { promises as fs } from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -63,6 +64,19 @@ function reuse(extraArguments = []) { } try { + await test("user-owned CLI installations are excluded from worktree copying", { timeout: 15000 }, () => { + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes("agent-cli-runtime")); + const refused = reuse(["--resource", "agent-cli-runtime"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /agent-cli-runtime.*cannot be reused/); + }); + await test("Agent history preferences and exports are excluded from worktree copying", { timeout: 15000 }, () => { + const refused = reuse(["--resource", "agent-history-metadata"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /agent-history-metadata.*lithe\.agent-history\.v1.*cannot be reused/); + }); await testFailedBackupPreservesDestination(); await fs.mkdir(path.join(sourceRoot, "third_party", "jdtls"), { recursive: true }); await fs.writeFile( diff --git a/scripts/verify-rust-core.sh b/scripts/verify-rust-core.sh index bd4af1fd8..09505d0d4 100755 --- a/scripts/verify-rust-core.sh +++ b/scripts/verify-rust-core.sh @@ -5,6 +5,9 @@ ROOT_DIR="${0:A:h:h}" cd "$ROOT_DIR" mkdir -p .artifacts/test-stability +node --test --test-reporter=spec --test-reporter-destination=stdout \ + --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/npm-progress.xml \ + rust/lithe-agent-host/tests/npm-progress.test.mjs node --test --test-reporter=spec --test-reporter-destination=stdout \ --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/rust-comment-checker.xml \ scripts/test-rust-core-comments.mjs @@ -15,6 +18,10 @@ node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ --manifest rust/Cargo.toml --package lithe-git-host \ --suite-timeout-ms 120000 \ --report .artifacts/test-stability/git-host-rust.json +node .agents/skills/write-stable-tests/scripts/run-rust-tests-with-timing.mjs \ + --manifest rust/Cargo.toml --package lithe-agent-host \ + --suite-timeout-ms 120000 \ + --report .artifacts/test-stability/agent-host-rust.json cargo test --manifest-path rust/Cargo.toml -p lithe-core case "$(uname -m)" in diff --git a/scripts/verify-shared-contracts.sh b/scripts/verify-shared-contracts.sh index 8320a2bf8..007188b89 100755 --- a/scripts/verify-shared-contracts.sh +++ b/scripts/verify-shared-contracts.sh @@ -91,6 +91,8 @@ fi abort "module IDs must be sorted" unless modules.map { |m| m.fetch("id") } == modules.map { |m| m.fetch("id") }.sort abort "workspace must be the only required module" unless modules.select { |m| m.fetch("required") }.map { |m| m.fetch("id") } == ["dev.lithe.workspace"] abort "AI must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.ai-assistance" }.fetch("defaultState") == "disabled" + agent = modules.find { |m| m.fetch("id") == "dev.lithe.agent-conversation" } + abort "Agent must be disabled and on demand" unless agent.fetch("defaultState") == "disabled" && agent.fetch("activationPolicy") == "onDemand" abort "Database must be disabled by default" unless modules.find { |m| m.fetch("id") == "dev.lithe.database" }.fetch("defaultState") == "disabled" allowed_states = ["enabled", "disabled"] allowed_scopes = ["application", "workspace"] diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 066fb2629..c1052e95d 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -28,30 +28,62 @@ } ], "excludedResources": [ + { + "id": "agent-cli-runtime", + "reusable": false, + "locations": ["npm global prefix and cache", "Homebrew Cellar, Caskroom and download cache", "user home .local/share/claude/versions"], + "identity": "Runtime PATH executable, package-manager ownership and package/version/platform/architecture validated by the owning installer; no worktree build identity stamp", + "reason": "User-managed runtime installations and mutable package-manager caches are outside worktrees. Never copy, symlink, publish or share them through build resource reuse. No copy stage is permitted." + }, { "id": "official-plugin-packages", "path": ".build///OfficialPlugins", - "reason": "原生插件产物绑定宿主 API、Swift 工具链、平台架构和签名;没有可靠 identity stamp,各工作树单独构建并用 LitheOfficialPluginVerifier 校验,不复制。" + "reusable": false, + "locations": [".build///OfficialPlugins"], + "identity": "Native plugin output is tied to the host API, Swift toolchain, platform architecture and signing; no reliable worktree identity stamp", + "reason": "Build each official plugin independently in every worktree and validate it with LitheOfficialPluginVerifier; do not copy or symlink it across worktrees." }, { "id": "language-tools", "path": "/language-tools//", - "reason": "用户显式安装的可变工具及锁文件属于运行时插件状态,没有工作树级 identity stamp;禁止跨工作树复用,由插件卸载清理。" + "reusable": false, + "locations": ["/language-tools//"], + "identity": "User-installed language tools and lock files are mutable runtime state without a worktree identity stamp", + "reason": "Do not reuse user-managed language tools across worktrees; plugin lifecycle owns installation and cleanup." }, { "id": "phpunit-fixture", "path": "shared/fixtures/phpunit-project/vendor", - "reason": "PHPUnit 依赖由测试工作树的 Composer 安装,身份取决于 PHP 和依赖解析;没有可靠 identity stamp,不复制 vendor。" + "reusable": false, + "locations": ["shared/fixtures/phpunit-project/vendor"], + "identity": "Composer dependencies depend on the test worktree PHP version and dependency resolution without a reliable identity stamp", + "reason": "Install PHPUnit dependencies independently in each worktree; do not copy vendor directories." }, { "id": "windows-worker-plugins", "path": ".artifacts/windows-plugins", - "reason": "独立 Worker 插件绑定包格式、宿主 SDK 与 Bun 构建版本;没有可靠 identity stamp,不跨工作树复制,每棵树独立构建并运行 verify-windows-plugin-isolation.mjs。" + "reusable": false, + "locations": [".artifacts/windows-plugins"], + "identity": "Worker plugins are tied to package format, host SDK and Bun build versions without a reliable identity stamp", + "reason": "Build worker plugins independently in each worktree and run verify-windows-plugin-isolation.mjs; do not copy them across worktrees." }, { "id": "installed-worker-plugins", "path": "/Local Storage/lithe.worker-package:*", - "reason": "用户导入的插件源码与安装状态属于应用配置,不是下载缓存;卸载按插件 ID 删除,没有工作树身份,不跨工作树复制。" + "reusable": false, + "locations": ["/Local Storage/lithe.worker-package:*"], + "identity": "Imported plugin source and install state belong to application configuration and have no worktree identity", + "reason": "Do not copy installed worker plugin state across worktrees; uninstall by plugin ID through the application lifecycle." + }, + { + "id": "agent-history-metadata", + "reusable": false, + "locations": [ + "platform preferences: lithe.agent-history.v1.", + "user-chosen Markdown export destination" + ], + "identity": "Mutable user annotations scoped by standardized workspace identity and configured Agent ID; no build, platform, architecture or toolchain stamp", + "reason": "Favorites, custom titles and hidden rows are runtime preferences. Never copy them across worktrees or read Agent files to reconstruct them; exports are user documents, not build resources. No copy stage is permitted." } ] } diff --git a/shared/contracts/application-boundary.md b/shared/contracts/application-boundary.md index 72fd815e5..f24a6e3cf 100644 --- a/shared/contracts/application-boundary.md +++ b/shared/contracts/application-boundary.md @@ -10,8 +10,8 @@ verification scripts are the executable source of boundary checks. - All payloads are UTF-8 JSON when exchanged across a process or language boundary. - Workspace paths are relative to the opened workspace and use `/` separators. -- Absolute paths may appear at native editor/process boundaries and as LSP - `file://` URIs, but are not persisted as cross-platform identifiers. +- Absolute paths may appear at native editor/process boundaries and as LSP/ACP + `file://` resource URIs, but are not persisted as cross-platform identifiers. - Product-facing line numbers are one-based. Editor/LSP positions explicitly use zero-based lines and UTF-16 columns. Missing locations are `null`. - Lists have deterministic ordering so contract fixtures can be compared directly. @@ -28,6 +28,7 @@ verification scripts are the executable source of boundary checks. | Git | changes, commits, branches, diffs, reviewed history actions and recovery, worktree listing and safe management, worktree-aware PR publication context, validation, and mutation results | Git executable discovery, credentials, process environment, opening checkout paths | | GitHub | remote parsing, trusted request plans, normalized branch comparisons and pull requests/reviews/comments, deterministic ordering, and stable errors | OAuth configuration, HTTPS, browser opening, and operating-system credential storage | | [AI commit messages](ai-commit.md) | provider configuration parsing, commit rules, bounded diff evidence, request plans, and response text | local configuration discovery, credentials, HTTP, cancellation, and draft UI | +| Agent conversation (ACP) | supported-agent catalog, Node.js/npm detection, adapter install with the user's npm and numbers-only live download progress, CLI provenance and owner-preserving updates, per-agent API-key and model delivery, ACP v1 connection per workspace and agent, agent-owned session history (list/load), session config options, user-selected file references as ACP resource links, streamed tool evidence, permission decisions, acknowledged cancellation with bounded recovery, and process-tree lifecycle in the shared Rust host | provider and agent settings, API-key storage, credential-independent local default-model reading through AI configuration ports, data directory, workspace selection, module enablement, conversation UI, workspace/Agent-scoped local history annotations (favorites, title overrides and recoverable hidden rows), and user-selected Markdown export destinations | | Runtime | Java/Maven requirements, normalized candidates, and effective toolchain references | JDK/Maven probing and executable paths | | Language tooling | provider catalog, local fallback results, complete LSP process/session runtime, capabilities, diagnostics, UTF-16 edits, and normalized feature results | executable/environment discovery and UI provider routing | | Java/Maven/Spring | deterministic Maven-root selection, project structure, modules and profiles, bounded dependency-tree normalization; compiler diagnostic parsing; Java source structure, symbols, code vision, run-configuration detection, Spring configuration/bean/endpoint indexing, and JDTLS/Java Debug adapter policy | JDK/Maven discovery, local dependency-repository selection, Java/Maven child processes, and sockets | @@ -86,7 +87,7 @@ contract must preserve these invariants: - Native plugin factories receive a read-only host context. Host services use stable IDs and shared protocols; plugin code cannot import a platform composition root or the application executable. -- AI Assistance, Terminal, Git, Search, Local History, Debug, and Java/Maven +- AI Assistance, Agent Conversation, Terminal, Git, Search, Local History, Debug, and Java/Maven execution are built-in lifecycle modules. They are not marketplace plugins. - Java language tooling remains part of the built-in product. Every other language provider is represented by an independently configurable bundled diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index f9580ff0c..405f861c1 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -12,6 +12,9 @@ int32_t lithe_core_git_askpass(const char *prompt); char *lithe_core_lsp_provider_catalog_json(const char *workspace_root); int32_t lithe_core_cancel(const char *operation_id); void lithe_core_free_string(char *value); +void *lithe_agent_open_json(const char *configuration, void (*callback)(const char *, void *), void *context); +int32_t lithe_agent_send_json(void *handle, const char *command); +void lithe_agent_close(void *handle); ``` The macOS package uses the small C bridge in `macos/Sources/LitheRustCore/`. The @@ -38,6 +41,91 @@ process exits. A replacement execution never shares its predecessor's file. Strings returned by the core are UTF-8 JSON allocated by Rust. The caller must release response strings with `lithe_core_free_string`. +The ACP Agent calls use an opaque handle for one agent process and connection +per workspace and agent; one connection carries many conversation sessions. +`lithe_agent_open_json` accepts `{ "agentId"?: string, "command"?: string, +"args": string[], "cwd": absolutePath, "dataDirectory"?: absolutePath, +"provider": { "protocol": "responses" | "chatCompletions" | "anthropicMessages", +"baseUrl": string, "apiKey": string, "name"?: string, "model"?: string, +"allowInsecureHttp"?: bool } }`. With `agentId`, the host starts the adapter +installed by `agent.install` under `dataDirectory`. Every agent signs in through +the ACP `gateway` method over stdio: Responses providers send +`Authorization: Bearer ` and Anthropic providers send `x-api-key`. The +user's own CLI is passed as `CODEX_PATH` or `CLAUDE_CODE_EXECUTABLE`, and a +non-empty `model` as `CODEX_CONFIG` or `ANTHROPIC_MODEL`. Without `agentId`, `command` runs +a user-provided agent that must support gateway sign-in with a Responses +provider. Agents start with the executable's directory and the login shell's +`PATH` first. Account logins offered by agents are never used. Invalid settings +and launch failures are reported as a `stopped` event with a message. + +`lithe_agent_send_json` queues one command: `newSession`, `loadSession`, +`listSessions`, `setConfigOption`, `prompt`, `cancel`, or `permission`. Results arrive as events: +`ready`, `sessionCreated`, `sessionLoaded`, `sessions`, `update`, `permission`, +`sessionConfigured`, `turnCancelling`, `turnFinished`, `requestFailed`, and `stopped`. Commands and events, including +their camel-case field names, are fixed by +`shared/fixtures/agent/acp-events-v1.json`; `token` values are echoed so a caller +can correlate concurrent requests. `stopReason` uses ACP wire names such as +`end_turn` and `cancelled`. + +`prompt` retains `text` and optionally carries ordered `files` entries with `uri` +(a native `file://` URL) and `name` (the display filename). The host validates up to +32 references and sends upstream ACP `resource_link` blocks after the text block; +empty text is allowed when files are present. It never reads, copies, or embeds +file bytes. These native resource URLs identify user-selected context, not portable +workspace records; the agent owns reading, permissions, and history. Invalid +references produce `requestFailed` before reserving a turn. Older text-only callers +remain compatible by omitting `files`. + +`sessionCreated` and `sessionLoaded` optionally carry the agent's `configOptions`. +`setConfigOption` carries `token`, `sessionId`, `configId`, and a select-option +string `value`; the host uses ACP `session/set_config_option`. Its acknowledged +full option list arrives as `sessionConfigured`. Consumers also accept ACP +`config_option_update` notifications. Agent-provided IDs, choices and current +values remain authoritative; unsupported controls are not synthesized. +Configuration failure echoes the token and does not finish a prompt. + +For a new session, the host also preserves the adapter's optional legacy model +catalog while decoding the ACP response and negotiates only the versioned +`jetbrains.air.recommendedValue` extension. If the configured current model is +absent from that catalog and the upstream recommendation is present in both the +catalog and selector, the host requests that model before publishing +`sessionCreated`. Only the acknowledged full configuration is exposed. Both +requests share the session creation deadline; rejection, timeout, or an +unconfirmed selection emits `requestFailed`. Valid configured models, loaded +history, and global CLI files remain unchanged. Missing or malformed optional +catalog/recommendation data leaves standard ACP behavior intact. The `upstream` +scenarios in the agent fixture protect this workflow without changing the +command/event JSON shape. + +A `cancel` answers pending permissions with `cancelled`, sends one +ACP `session/cancel`, and reports `turnCancelling`. The session remains busy +until its prompt response arrives. A second prompt and configuration changes +are rejected while it is busy. If the agent fails to acknowledge within ten +seconds, the connection fails and its process tree is stopped; clients retain +the visible transcript and offer reconnect followed by `session/load`. This +explicit recovery prevents another message from entering a lost cancelled turn. +Other sessions on the same process are also detached on this failure. +Normal cancellation does not restart the process. + +ACP `usage_update` notifications are forwarded unchanged in `update`, with +`used` (tokens currently in context) and `size` (context window capacity), scoped +by `sessionId`. Consumers replace the previous snapshot, allowing usage to drop +after compaction; these values are not cumulative billing tokens. Missing or +invalid data and a zero capacity represent unknown usage, not an empty window. +The macOS indicator clears stale capacity on disconnect or confirmed model +changes and waits for a new report; it does not infer limits from model names. + +Tool updates preserve ACP `kind`, `locations`, `rawInput`, `rawOutput`, and +`content` (including diffs). Partial updates replace only fields supplied by +the agent. Permission displays combine already received tool details with the +permission request, and distinguish allow/reject option kinds. + +The caller must close each handle +exactly once; closing revokes callbacks and stops the process tree, force +killing processes that do not exit after a short grace period. The callback +context must remain valid until close returns. This API is owned by +`lithe-agent-host` and is separate from the synchronous JSON command envelope. + ## Envelope The typed `lithe_core::ai` API provides credential-free commit request planning, @@ -80,6 +168,80 @@ stable error code and a user-facing message: ## Commands +### Agent adapters + +`agent.parseProviderConfiguration` takes `{source, configuration}` with `source` +equal to `codex` (TOML) or `claude` (JSON). It reuses the typed AI configuration +parsers with an empty environment and returns provider metadata only, without +credentials or credential-presence flags. Configuration text is limited to +64 KiB UTF-8. Invalid source, malformed text and oversized input return +`invalid_request` without raw input or parser diagnostics. The fixture is +`shared/fixtures/agent/provider-configuration-v1.json`. Hosts extract explicit +API keys, validate Agent protocol compatibility, and persist credentials in their +native vault. This operation does not discover or write local CLI files. + +`agent.status`, `agent.install`, `agent.uninstall`, and `agent.installCli` manage ACP adapters in +`/agents/`, using the Node.js and npm the user installed. +Lithe never installs Node.js or npm; the agents' own command-line tools are installed only through `agent.installCli` on an explicit user action. `agent.status` +detects Node.js and npm through the login shell's `PATH` and lists every +supported agent with its pinned version, installed version, provider protocol, +and blocking issues. Adapters that drive the agent's own CLI (Codex, Claude Code) report the +CLI found on that `PATH` with its minimum version; they are installed with +`--omit=optional`, so their bundled CLI copy is not downloaded, and are launched +with the user's CLI through the adapter's variable (`CODEX_PATH`, +`CLAUDE_CODE_EXECUTABLE`). A missing or +too old CLI is an issue for the user to resolve, never installed by Lithe. +`agent.install` runs `npm install` into a staging directory +and replaces the previous install only after the adapter executable exists; it +honors `operationId` cancellation and `timeoutMilliseconds`. Failures use +`runtime_missing` (Node.js or npm unusable), `process_failed` (npm failed, with +its output tail), `invalid_request`, `cancelled`, or `timed_out`. Payloads and +results are fixed by `shared/fixtures/agent/agent-management-v1.json`. + +`agent.installCli` resolves the current PATH executable and its installation +owner again on each explicit action. A missing CLI uses npm; an existing npm CLI +requires the selected npm's global root, package manifest/bin and active link +to agree before `npm install -g @latest`. Homebrew requires the active +target to belong to its reported Caskroom/Cellar and an installed package receipt; +it runs `brew upgrade --cask/--formula `, retaining the installed +channel. A standard Claude native launcher uses `claude update`. Unknown, +broken, unrecorded, or mismatched Node/npm installations require manual updating; +there is no force overwrite, installer migration or automatic npm fallback. +After completion, the host refreshes the login-shell PATH and requires the CLI +selected there to meet the adapter's minimum version before returning `cliVersion`. +The successful response also includes optional `updaterWarning` (null for a clean +exit, a bounded output tail for a recovered installer failure). A nonzero exit +can succeed only if the CLI is now present and usable, and its numeric version +strictly increased compared with the pre-update CLI (or it was previously absent). +An unchanged, downgraded, missing or still-too-old CLI remains `process_failed`; +cancellation, timeout and process-start failure never recover through a version +probe. Hosts show the verified version as success and keep any warning/log separate +from errors. Older responses without `updaterWarning` decode as a clean result. +Node.js and npm remain user-managed. Detection is read-only and locally bounded. + +`agent.status` includes optional `cli.installation` with `source` (`npm`, +`homebrew`, `native`, `missing`, `unknown`), `canUpdate`, and display-only +`updateHint`. Hosts show the source and guidance, and offer automatic update +only when `canUpdate` is true. The hint is never executable input. Absent fields +remain backward compatible with older hosts. Windows installer/shim ownership +has not been verified; unrecognized installations use the manual path. + +`agent.install` and `agent.installCli` publish `agentInstallProgress` through the +existing synchronous `execute_json_with_events`/C ABI event callback. Each event +carries the request's `operationId` and a `progress` object: `stage` (`preparing`, +`downloading`, `installing`, `updating`), `downloadedBytes` (received archive body bytes), +`bytesPerSecond` (most recent sample), `elapsedMilliseconds`, and +`idleMilliseconds` (since the last archive bytes). Counters contain no URLs, +headers, credentials, or paths. npm still owns fetching, proxies, retries, cache, +and extraction. A built-in Node observer counts bytes without consuming its +stream and restores inherited `NODE_OPTIONS` before npm starts child scripts. +No total or overall percentage is supplied: npm can discover additional packages +and may use cached packages. Events stop before the final response, including +failure, timeout, and cancellation. Hosts reject stale operation IDs and clear +live counters at completion. Examples are in `agent-management-v1.json`. +Homebrew and native updaters emit `updating` with zero transfer counters: their +package manager owns the download and Lithe does not infer bytes from logs. + | Command | Purpose | | --- | --- | | `core.ping` | Verify the ABI and protocol version | @@ -295,8 +457,22 @@ workspace containment, depth, and path. Each entry contains an absolute native `path` because repository roots are platform boundary values and may be outside the opened folder when the folder is nested inside a checkout. Canonical paths are reported in plain native form: Core strips the Windows verbatim `\\?\` -prefix so roots remain valid Git working directories and stay resolvable after -consumers normalize separators. Core treats both +prefix only for supported drive/UNC paths so roots remain valid Git working +directories after consumers normalize separators. On Windows, path components +ending in an ASCII dot or space, reserved DOS device basenames, verbatim dot +segments, and non-drive/non-UNC device namespaces are +unsupported: discovery, status, watch context and Git reads return +`invalid_request` before filesystem lookup, rather than aliasing another path. +The same validation applies after canonicalization and to Git-reported paths; +missing-worktree fallbacks must not suppress it. Frontend file resolution +normalizes native UNC/verbatim inputs before deciding whether to join a repository +root, and rejects unsupported names before stripping their prefix. Remote/WSL +identifiers retain their protocol and POSIX name semantics. Chinese names, +embedded spaces and long paths are not rejected by length; native Git/filesystem +errors remain visible. In particular, Windows may reject an over-MAX_PATH Git +working directory at process creation even when filesystem lookup succeeds; +this is reported as `process_start_failed`, not a missing repository. Shared examples live in `shared/fixtures/git/windows-paths.json`. +Core treats both `.git` directories and `.git` files as repository markers. The default traversal visits the entire workspace tree, including build and dependency folders, and continues below discovered repositories. Git metadata itself is not traversed. @@ -1435,7 +1611,10 @@ Range-only providers are not advertised as supporting this operation. No delta result ID crosses the boundary. See `shared/fixtures/lsp/semantic-tokens-v1.json`. The `semanticTokensRefresh` event invalidates the host's semantic color cache when the server requests `workspace/semanticTokens/refresh`; the request receives -a JSON-RPC null acknowledgment. +a JSON-RPC null acknowledgment. Windows maps `lsp_get_semantic_tokens` to this +existing operation and forwards refresh events as `lsp://semantic-tokens-refresh` +with `{ sessionId, workspacePath }`; only the owning frontend session invalidates +its Monaco provider. The shared payload and legend remain unchanged. The `virtualDocument` operation accepts `{ sessionId, operation, virtualUri }` without a document `uri`. Its terminal `requestCompleted` event returns `{ text }`, where `text` is the provider-resolved UTF-8 source for the diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json new file mode 100644 index 000000000..cb4d0f8c8 --- /dev/null +++ b/shared/fixtures/agent/acp-events-v1.json @@ -0,0 +1,173 @@ +{ + "version": 1, + "commands": { + "newSession": { "kind": "newSession", "token": "token-1" }, + "loadSession": { "kind": "loadSession", "token": "token-2", "sessionId": "session-1" }, + "listSessions": { "kind": "listSessions", "token": "token-3" }, + "prompt": { "kind": "prompt", "sessionId": "session-1", "text": "Explain this project" }, + "promptWithFiles": { "kind": "prompt", "sessionId": "session-1", "text": "Explain these files", "files": [{ "uri": "file:///example/project/Hello%20World.swift", "name": "Hello World.swift" }, { "uri": "file:///example/project/README.md", "name": "README.md" }] }, + "fileOnlyPrompt": { "kind": "prompt", "sessionId": "session-1", "text": "", "files": [{ "uri": "file:///example/project/README.md", "name": "README.md" }] }, + "cancel": { "kind": "cancel", "sessionId": "session-1" }, + "setConfigOption": { "kind": "setConfigOption", "token": "config-1", "sessionId": "session-1", "configId": "model", "value": "example-model" }, + "allowPermission": { "kind": "permission", "requestId": "permission-1", "optionId": "allow_once" }, + "denyPermission": { "kind": "permission", "requestId": "permission-1", "optionId": null } + }, + "events": { + "ready": { + "kind": "ready", + "agentName": "example-agent", + "agentVersion": "1.0.0", + "canLoadSessions": true, + "canListSessions": true + }, + "sessionCreated": { "kind": "sessionCreated", "token": "token-1", "sessionId": "session-1" }, + "sessionLoaded": { "kind": "sessionLoaded", "token": "token-2", "sessionId": "session-1" }, + "sessionConfigured": { + "kind": "sessionConfigured", "token": "config-1", "sessionId": "session-1", + "configOptions": [ + { "id": "model", "name": "Model", "category": "model", "type": "select", "currentValue": "example-model", "options": [{ "value": "example-model", "name": "Example Model" }] }, + { "id": "mode", "name": "Permissions", "category": "mode", "type": "select", "currentValue": "read-only", "options": [{ "value": "read-only", "name": "Read Only" }, { "value": "auto", "name": "Auto" }] }, + { "id": "reasoning_effort", "name": "Reasoning", "category": "thought_level", "type": "select", "currentValue": "medium", "options": [{ "value": "medium", "name": "Medium" }, { "value": "high", "name": "High" }] } + ] + }, + "turnCancelling": { "kind": "turnCancelling", "sessionId": "session-1" }, + "sessions": { + "kind": "sessions", + "token": "token-3", + "sessions": [ + { "sessionId": "session-1", "title": "Explain this project", "updatedAt": "2026-09-25T10:00:00Z" }, + { "sessionId": "session-2", "title": null, "updatedAt": null } + ] + }, + "userMessageChunk": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "user_message_chunk", "content": { "type": "text", "text": "Explain this project" } } + }, + "agentMessageChunk": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "This project **builds** an IDE." } } + }, + "toolCall": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "tool_call", "toolCallId": "call-1", "title": "Run tests", "kind": "execute", "rawInput": { "command": "node --test" }, "locations": [{ "path": "src/main.js", "line": 1 }] } + }, + "toolCallUpdate": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "tool_call_update", "toolCallId": "call-1", "status": "completed", "content": [{ "type": "content", "content": { "type": "text", "text": "1 test passed" } }], "rawOutput": { "exitCode": 0 } } + }, + "sessionInfo": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "session_info_update", "title": "Project overview" } + }, + "usageUpdate": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "usage_update", "used": 18700, "size": 258400 } + }, + "permission": { + "kind": "permission", + "sessionId": "session-1", + "requestId": "permission-1", + "request": { + "sessionId": "session-1", + "toolCall": { "toolCallId": "call-1", "title": "Run tests", "rawInput": { "command": "node --test" } }, + "options": [ + { "optionId": "allow_once", "name": "Allow Once", "kind": "allow_once" }, + { "optionId": "reject_once", "name": "Reject", "kind": "reject_once" } + ] + } + }, + "turnFinished": { "kind": "turnFinished", "sessionId": "session-1", "stopReason": "end_turn" }, + "turnCancelled": { "kind": "turnFinished", "sessionId": "session-1", "stopReason": "cancelled" }, + "requestFailed": { + "kind": "requestFailed", + "token": null, + "sessionId": "session-1", + "message": "The Agent is still responding in this conversation" + }, + "stopped": { "kind": "stopped", "message": "The Agent connection closed unexpectedly" } + }, + "upstream": { + "staleModelSession": { + "sessionId": "session-repaired", + "models": { + "currentModelId": "model-retired[max]", + "availableModels": [ + { + "modelId": "model-current[max]", + "name": "Current Max" + }, + { + "modelId": "model-other[medium]", + "name": "Other Medium" + } + ] + }, + "configOptions": [ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "model-retired", + "options": [ + { + "value": "model-retired", + "name": "model-retired" + }, + { + "value": "model-current", + "name": "model-current" + }, + { + "value": "model-other", + "name": "model-other" + } + ], + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "recommendedValue": "model-current" + } + } + } + } + ] + }, + "repairedModelConfiguration": { + "configOptions": [ + { + "id": "model", + "name": "Model", + "category": "model", + "type": "select", + "currentValue": "model-current", + "options": [ + { + "value": "model-current", + "name": "model-current" + }, + { + "value": "model-other", + "name": "model-other" + } + ], + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "recommendedValue": "model-current" + } + } + } + } + ] + } + } +} diff --git a/shared/fixtures/agent/agent-management-v1.json b/shared/fixtures/agent/agent-management-v1.json new file mode 100644 index 000000000..297f7c4bc --- /dev/null +++ b/shared/fixtures/agent/agent-management-v1.json @@ -0,0 +1,200 @@ +{ + "version": 1, + "requests": { + "status": { + "command": "agent.status", + "payload": { + "dataDirectory": "/Users/example/Library/Application Support/Lithe" + } + }, + "install": { + "command": "agent.install", + "payload": { + "dataDirectory": "/Users/example/Library/Application Support/Lithe", + "agentId": "codex-acp" + } + }, + "uninstall": { + "command": "agent.uninstall", + "payload": { + "dataDirectory": "/Users/example/Library/Application Support/Lithe", + "agentId": "codex-acp" + } + }, + "installCli": { + "command": "agent.installCli", + "payload": { + "dataDirectory": "/Users/example/Library/Application Support/Lithe", + "agentId": "codex-acp" + } + } + }, + "responses": { + "status": { + "environment": { + "node": { + "version": "20.11.0", + "path": "/opt/example/node/bin/node" + }, + "npm": { + "version": "10.2.4", + "path": "/opt/example/node/bin/npm" + }, + "usedLoginShell": true + }, + "agents": [ + { + "id": "codex-acp", + "name": "Codex", + "description": "OpenAI Codex through its ACP adapter", + "package": "@agentclientprotocol/codex-acp", + "version": "1.13.1", + "installedVersion": "1.13.1", + "protocol": "responses", + "minimumNodeMajor": 20, + "verified": true, + "cli": { + "name": "Codex CLI", + "command": "codex", + "minimumVersion": "0.156.0", + "installHint": "npm install -g @openai/codex", + "detected": { + "version": "0.156.1", + "path": "/opt/example/node/bin/codex" + }, + "package": "@openai/codex", + "installation": { + "source": "npm", + "canUpdate": true, + "updateHint": "npm install -g @openai/codex" + } + }, + "issues": [] + }, + { + "id": "claude-acp", + "name": "Claude", + "description": "Claude Agent through its ACP adapter", + "package": "@agentclientprotocol/claude-agent-acp", + "version": "0.81.2", + "installedVersion": null, + "protocol": "anthropicMessages", + "minimumNodeMajor": 22, + "verified": false, + "cli": { + "name": "Claude Code", + "command": "claude", + "minimumVersion": "2.1.280", + "installHint": "npm install -g @anthropic-ai/claude-code", + "detected": null, + "package": "@anthropic-ai/claude-code", + "installation": { + "source": "missing", + "canUpdate": true, + "updateHint": "npm install -g @anthropic-ai/claude-code" + } + }, + "issues": [ + "Claude needs Node.js 22 or later; found 20.11.0.", + "Claude Code was not found. Install it (for example `npm install -g @anthropic-ai/claude-code`), then check again." + ] + } + ] + }, + "install": { + "agentId": "codex-acp", + "installedVersion": "1.13.1" + }, + "uninstall": { + "agentId": "codex-acp" + }, + "installCli": { + "agentId": "codex-acp", + "cliVersion": "0.156.1", + "updaterWarning": null + }, + "installCliRecovered": { + "agentId": "codex-acp", + "cliVersion": "0.157.1", + "updaterWarning": "Download failed; retry completed" + } + }, + "events": { + "preparing": { + "kind": "agentInstallProgress", + "operationId": "install-example", + "progress": { + "stage": "preparing", + "downloadedBytes": 0, + "bytesPerSecond": 0, + "elapsedMilliseconds": 0, + "idleMilliseconds": 0 + } + }, + "downloading": { + "kind": "agentInstallProgress", + "operationId": "install-example", + "progress": { + "stage": "downloading", + "downloadedBytes": 25000000, + "bytesPerSecond": 75000, + "elapsedMilliseconds": 300000, + "idleMilliseconds": 0 + } + }, + "waiting": { + "kind": "agentInstallProgress", + "operationId": "install-example", + "progress": { + "stage": "downloading", + "downloadedBytes": 25000000, + "bytesPerSecond": 0, + "elapsedMilliseconds": 320000, + "idleMilliseconds": 20000 + } + }, + "updating": { + "kind": "agentInstallProgress", + "operationId": "install-example", + "progress": { + "stage": "updating", + "downloadedBytes": 0, + "bytesPerSecond": 0, + "elapsedMilliseconds": 0, + "idleMilliseconds": 0 + } + } + }, + "cliInstallations": { + "npm": { + "source": "npm", + "canUpdate": true, + "updateHint": "npm install -g @openai/codex" + }, + "homebrew": { + "source": "homebrew", + "canUpdate": true, + "updateHint": "brew upgrade --cask codex" + }, + "native": { + "source": "native", + "canUpdate": true, + "updateHint": "claude update" + }, + "missing": { + "source": "missing", + "canUpdate": true, + "updateHint": "npm install -g @anthropic-ai/claude-code" + }, + "unknown": { + "source": "unknown", + "canUpdate": false, + "updateHint": "Update this CLI using its original installer, then check again." + }, + "npmEnvironmentMismatch": { + "source": "npm", + "canUpdate": false, + "updateHint": "Switch to the Node.js/npm environment that installed this CLI, update it there, then check again." + } + } +} diff --git a/shared/fixtures/agent/provider-configuration-v1.json b/shared/fixtures/agent/provider-configuration-v1.json new file mode 100644 index 000000000..c3d1b95a1 --- /dev/null +++ b/shared/fixtures/agent/provider-configuration-v1.json @@ -0,0 +1,15 @@ +{ + "schemaVersion": 1, + "cases": [ + { + "source": "codex", + "configuration": "model = 'unused'\nprofile = 'work'\n[profiles.work]\nmodel = 'fixture-model'\nmodel_provider = 'gateway'\n[model_providers.gateway]\nbase_url = 'https://example.test/v1'\nwire_api = 'responses'\nrequires_openai_auth = true\n", + "expected": {"endpoint": "https://example.test/v1", "model": "fixture-model", "apiProtocol": "responses"} + }, + { + "source": "claude", + "configuration": "{\"env\":{\"ANTHROPIC_BASE_URL\":\"https://example.test/v1\",\"ANTHROPIC_MODEL\":\"sonnet\",\"ANTHROPIC_DEFAULT_SONNET_MODEL\":\"fixture-model\",\"ANTHROPIC_API_KEY\":\"fixture-secret\"}}", + "expected": {"endpoint": "https://example.test/v1", "model": "fixture-model", "apiProtocol": "anthropicMessages"} + } + ] +} diff --git a/shared/fixtures/git/windows-paths.json b/shared/fixtures/git/windows-paths.json new file mode 100644 index 000000000..61e6d37ce --- /dev/null +++ b/shared/fixtures/git/windows-paths.json @@ -0,0 +1,59 @@ +{ + "accepted": [ + { + "input": "\\\\?\\C:\\work\\repo", + "normalized": "C:/work/repo" + }, + { + "input": "\\\\?\\UNC\\server\\share\\repo", + "normalized": "//server/share/repo" + }, + { + "input": "\\\\?\\unc\\server\\share\\repo", + "normalized": "//server/share/repo" + }, + { + "input": "\\\\server\\share\\repo", + "normalized": "//server/share/repo" + }, + { + "input": "C:\\项目 space\\repo", + "normalized": "C:/项目 space/repo" + }, + { + "input": "//?/C:/work/repo", + "normalized": "C:/work/repo" + }, + { + "input": "C:\\", + "normalized": "C:/" + }, + { + "input": "/work/repo", + "normalized": "/work/repo" + }, + { + "input": "\\\\server.example.\\share\\repo", + "normalized": "//server.example./share/repo" + }, + { + "input": "\\\\?\\UNC\\server.example.\\share\\repo", + "normalized": "//server.example./share/repo" + } + ], + "rejected": [ + "\\\\?\\C:\\work\\repo.", + "\\\\?\\C:\\work\\repo ", + "\\\\?\\C:\\work.\\repo", + "\\\\?\\UNC\\server\\share\\repo.\\src", + "\\\\server\\share\\repo ", + "C:\\work\\repo.", + "//?/C:/work/repo ", + "\\\\.\\C:\\work\\repo", + "\\\\?\\Volume{fixture}\\repo", + "\\\\?\\C:\\work\\NUL.txt", + "\\\\?\\C:\\work\\COM1", + "\\\\?\\C:\\work\\..\\repo", + "\\\\?\\UNC\\server" + ] +} diff --git a/shared/fixtures/modules/built-in-v1.json b/shared/fixtures/modules/built-in-v1.json index 2404a6ccb..fc507989f 100644 --- a/shared/fixtures/modules/built-in-v1.json +++ b/shared/fixtures/modules/built-in-v1.json @@ -1,6 +1,20 @@ { "version": 1, "modules": [ + { + "id": "dev.lithe.agent-conversation", + "displayName": "Agent Conversation", + "scope": "application", + "defaultState": "disabled", + "activationPolicy": "onDemand", + "sleepPolicy": { "kind": "never" }, + "dependencies": [], + "capabilities": ["dev.lithe.capability.agent-conversation"], + "contributions": [ + { "id": "agent.conversation", "kind": "toolWindow", "actionID": "agent.conversation.toggle", "rendererID": "agent.conversation" } + ], + "required": false + }, { "id": "dev.lithe.ai-assistance", "displayName": "AI Assistance", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index e8ae8e489..f8308ed12 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -1,6 +1,6 @@ { "schemaVersion": 3, - "lastReviewed": "2026-09-24", + "lastReviewed": "2026-09-25", "review": { "status": "initial-static-inventory", "method": "根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。", @@ -50,6 +50,188 @@ } }, "features": [ + { + "id": "agent-entry-discoverability", + "area": "AI", + "group": "Agent 对话", + "capability": "新安装及模块关闭时仍显示 Agent 侧栏入口,面板立即提示关闭或未配置原因并提供设置入口,启停后入口不重复或消失", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Application/Features/AgentConversationEntryPolicy.swift", + "macos/Sources/Lithe/Views/Agent/AgentConversationView.swift", + "macos/Tests/LitheTests/AgentConversationEntryPolicyTests.swift" + ] + }, + "windows": { + "implementationStatus": "missing", + "verificationStatus": "not-applicable", + "evidence": ["windows/tauri/src/features"] + }, + "owner": "Agent", + "verification": "使用无本机模块配置的 macOS 测试账户启动并打开项目,确认 Agent 入口可见、默认开关关闭、面板显示设置指引;开启再关闭后入口和面板保留且不重复。确认读取入口不调用模块 factory、关闭状态无 Agent 连接或进程。Windows 尚无 Agent 对话 UI。" + }, + { + "id": "agent-conversation-history", + "area": "AI", + "group": "Agent 对话", + "capability": "CC GUI 风格历史页:标题与会话 ID 搜索、更新时间、复制 ID、刷新与恢复、收藏与本地重命名、单条与批量移除确认及恢复、筛选、多选批量管理及 Markdown 导出", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift", + "macos/Sources/Lithe/Platform/MacOS/Agent/MacAgentHistoryAdapters.swift", + "macos/Tests/LitheTests/AgentHistoryTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host/src/lib.rs" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:在深浅主题与窄宽面板查看历史页,验证标题/ID 搜索、空结果、返回保留草稿、刷新失败不丢列表、打开历史恢复会话;验证收藏/重命名重启后保留且不同项目/Agent 隔离;筛选和全选只操作可见项,单条与批量移除先确认,取消不修改记录,确认只移除提示时选中的会话,移除后可恢复且不改上游文件;单个与批量 Markdown 导出等待历史回放完成、保持原选中标签,取消/失败不写部分记录;部分回放后失败再导出必须重新加载并等待完成,断连后的未完整回放不可导出,完整快照断连后仍可导出;关闭项目释放导出等待。消息数量只显示已加载的用户与 Agent 消息,未知总数不伪造。Windows 页面待实现。" + }, + { + "id": "agent-context-usage", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 上下文用量:输入框圆环百分比、悬停已用/容量 token,未使用时零占位、会话隔离与压缩后更新", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentContextUsage.swift", + "macos/Sources/Lithe/Views/Agent/AgentContextUsageView.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host/src/lib.rs", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS:真实 Agent 对话后检查输入框顶部圆环百分比;分别悬停圆环与百分比,立即在上方显示已用/容量 token,移开或切换面板后关闭浮层,窄面板浮层不越界;首次、Agent 未上报或已上报零使用量时显示 0%,悬停仅显示“上下文: 0.0%”,内部缺失状态不伪造 token 或容量;切换会话/Agent、新建会话不串用量,压缩后用量可降低,不累加计费用量;模型确认切换和断连后清除旧容量,重新加载或上报后更新;窄宽面板及深浅主题检查布局和辅助功能。Windows 共享 host 可转发 usage_update,页面待实现。" + }, + { + "id": "agent-acp-conversation", + "area": "AI", + "group": "Agent 对话", + "capability": "可选 ACP Agent 对话:API Key 网关登录、项目会话与历史恢复、模型/权限/思考配置、不依赖凭据的本机默认模型同步、旧默认模型按上游推荐修复与品牌模型图标、CC GUI 风格可搜索模型与权限弹出面板、工具详情与权限、停止确认和进程清理", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule", + "macos/Sources/Lithe/Views/Agent", + "rust/lithe-agent-host" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS + Codex + API Key:验证发消息前的上游配置选项和确认;无自定义服务商或 API Key 时确认仍显示本机顶层默认模型、保留密钥缺失错误;修改本机默认模型后重连,连续新建空会话确认不再使用旧导入模型;配置不在上游目录中的旧模型时确认新会话使用目录内的上游推荐值、拒绝与超时不显示假成功且 Codex 模型菜单显示 OpenAI 标志;验证工具输入/输出/文件/变更、权限拒绝、停止确认及超时重连、历史加载失败保留记录;临时项目跑通读文件→修改→执行测试→追问,复用文档观察和脏缓冲区保存保护。检查模型搜索(名称/ID/分组、空结果)、品牌图标/选中状态、无模型说明、权限说明、思考/速度子菜单、确认/失败与会话切换关闭弹出面板;检查项目切换、退出清理,以及 CC GUI 风格空态、搜索、标签、可调整输入区和配置菜单在窄宽面板及深浅主题下的布局。Windows 与 Claude 端到端仍待验证。" + }, + { + "id": "agent-file-references", + "area": "AI", + "group": "Agent 对话", + "capability": "从 Finder 或项目树拖入文件到 Agent 输入框,文件选择器、可移除标签、多文件去重和数量限制、文件引用随消息发送及失败保留草稿", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentFileReference.swift", + "macos/Tests/LitheTests/AgentFileReferenceTests.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "rust/lithe-agent-host/src/tests.rs" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-agent-host/src/prompt.rs", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "macOS:从 Finder、项目树拖入临时文件(多文件、中文/空格名)到输入区与上下文栏,检查高亮和可移除标签、去重、数量限制、会话切换和发送失败保留草稿;用隔离项目验证文字+文件与纯文件发送、历史加载后引用正确送到原会话、Agent 读取与权限流程。图片按文件引用处理,无多模态上传。Windows 原生拖放和 Claude 端到端待验证。" + }, + { + "id": "agent-management", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 管理:面板内设置、Node.js/npm 检测、预检清单、ACP 适配器与 Agent CLI 一键安装或升级、一键获取本机 CLI 配置、实时下载数据量/速度/耗时与等待提示、按 CLI 安装来源更新、验证实际升级结果并区分成功警告与失败", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentPanelSettingsView.swift", + "macos/Sources/Lithe/Application/Features/AgentManagementFeatureModel.swift", + "rust/lithe-agent-host/src/install.rs", + "rust/lithe-core/src/agent/mod.rs", + "rust/lithe-agent-host/src/npm-progress.mjs", + "rust/lithe-agent-host/src/cli_update.rs", + "macos/Tests/LitheTests/AgentManagementFeatureModelTests.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "rust/lithe-core/src/agent/mod.rs" + ], + "implementationStatus": "partial", + "verificationStatus": "pending" + }, + "owner": "Agent", + "verification": "在 macOS 打开 Agent 面板右上角的设置:确认显示 Node.js 与 npm 版本,Node 缺失或版本过低时只提示;一键安装、取消安装、更新与卸载 Codex 适配器;CLI 过旧时确认显示 npm/Homebrew/原生来源并通过原安装器升级,npm 环境不匹配或未知来源时只显示手动指引,升级后确认实际 PATH 中的版本达到要求;模拟安装器先下载失败再重试成功但返回非零,确认版本确实提升时显示成功与折叠警告日志,版本未变/仍旧/丢失、取消和超时继续按原语义处理;确认下载数据量、速度、耗时实时更新且未知总量不显示百分比,取消/失败/完成后进度清除;一键获取本机 Codex 配置后在 Agent 面板对话,且提交信息所用服务商不变;Windows 待接入设置界面。" + }, + { + "id": "agent-provider-configuration", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 供应商管理:读取本机配置、自定义 Codex/Claude 配置文本、添加与编辑、安全保存密钥、启用与重连、确认删除及取消关联", + "macos": { + "implementationStatus": "implemented", + "verificationStatus": "pending", + "evidence": [ + "macos/Sources/Lithe/Views/Agent/AgentProviderConfigurationView.swift", + "macos/Sources/Lithe/Platform/MacOS/UI/MacConfigurationTextEditor.swift", + "macos/Sources/Lithe/Application/Features/AgentProviderConfiguration.swift", + "macos/Sources/Lithe/Models/AppModel/AppModel+AgentProviders.swift", + "macos/Tests/LitheTests/AgentProviderConfigurationTests.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift" + ] + }, + "windows": { + "implementationStatus": "partial", + "verificationStatus": "pending", + "evidence": [ + "rust/lithe-core/src/ai/agent_configuration.rs", + "shared/fixtures/agent/provider-configuration-v1.json" + ] + }, + "owner": "Agent", + "verification": "macOS:验证本机 Codex/Claude 配置读取、刷新与取消关联;添加自定义 TOML/auth JSON 或 Claude settings JSON,检查格式化、输入直引号不被自动替换、弯引号 TOML 错误在保存按钮上方始终可见、非法配置和缺失密钥、取消不保存、编辑保持 ID、删除确认及安全存储失败不丢失配置。在一个窗口保存并等待连接关闭期间,由另一窗口删除或修改同一供应商,确认旧保存失败且不恢复/覆盖供应商、密钥或 Agent 关联。检查窄宽面板、深浅主题、中英文本。空闲切换后确认请求到所选地址和模型;未发送空会话切换或意外退出后重新创建,不向旧 ID 加载,首条消息与文件只发送一次;已有消息、上游列出或成功加载的会话继续恢复,历史加载失败保留记录;正在响应、加载、权限待处理或配置确认时拒绝切换。提交服务商选择不被添加/启用改变。CLI 文件与 bundle 不被写入。Windows 编辑器及 Claude 真实请求待验证。" + }, { "id": "workspace-open-switch", "area": "工作区", @@ -135,13 +317,16 @@ "windows": { "evidence": [ "windows/tauri/src/features/workspace", - "windows/tauri/src/features/tabs" + "windows/tauri/src/features/tabs", + "windows/tauri/src-tauri/src/project_windows.rs", + "windows/tauri/src-tauri/src/project_window_registry.rs", + "windows/tauri/src/features/window/services/project-window-router.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Workspace", - "verification": "同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。" + "verification": "同时打开多个项目和文件,确认标签、项目上下文和关闭恢复行为。 Windows 重复打开同一本地目录(含大小写、分隔符和目录链接别名)时,恢复并聚焦已有窗口及项目标签;连续打开只产生一个窗口,关闭或打开失败后可以重试。" }, { "id": "editor-text-tabs", @@ -165,7 +350,7 @@ "verificationStatus": "pending" }, "owner": "Editor", - "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态。" + "verification": "打开、编辑、保存、关闭和恢复多个文本文件,确认光标、脏状态和标签状态;在 macOS 打开无扩展名文本文件,确认标签与项目树图标一致;从外部改为二进制后折叠并展开项目树,确认两处图标更新一致。" }, { "id": "editor-language-basics", @@ -192,6 +377,35 @@ "owner": "Editor", "verification": "分别打开 Java、Markdown 和普通文本,确认语言识别、语法高亮和模型切换;开启缩略图并滚动,确认滚动条轨道不透出编辑器代码。" }, + { + "id": "editor-semantic-highlighting", + "area": "编辑器", + "group": "文本编辑", + "capability": "LSP 语义高亮", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Models/AppModel/AppModel+LanguageEditing.swift", + "macos/Sources/LitheLanguageIntelligenceModule/Runtime/LanguageServerSession.swift", + "frontend/editor/src/semantic-tokens.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/platform/lsp-core-adapter.ts", + "windows/tauri/src/features/editor/lsp/lsp-client.ts", + "windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts", + "windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts", + "windows/tauri/src/platform/lsp-core-adapter.test.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Editor / Language Tooling", + "verification": "打开普通 Java 文件并等待 JDTLS 就绪,开启语义高亮,确认字段、方法等按协商图例上色;编辑、切换、关闭文档和重连服务器后不应用旧结果;服务器 refresh 后重新请求;关闭设置或大文件降级时保留基础语法高亮。", + "notes": "Windows 复用 Core semanticTokens 和共享 fixture,修复 #675;Linux 上的适配器与模型测试不替代 Windows WebView2/JDTLS 实机验收。" + }, { "id": "editor-multiline-tabs", "area": "编辑器", @@ -322,13 +536,16 @@ "windows": { "evidence": [ "windows/tauri/src/features/git", - "shared/contracts/application-boundary.md" + "shared/contracts/application-boundary.md", + "windows/tauri/src/features/git/api/git-repository-path.ts", + "rust/lithe-core/tests/git_path_roundtrip.rs", + "shared/fixtures/git/windows-paths.json" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。" + "verification": "修改、暂存、取消暂存并提交文件,确认状态、提交消息和错误回显。 Windows 另验证原生 UNC/verbatim 输入、中文/空格/长路径仓库往返及 linked worktree;末尾点/空格必须明确拒绝,外部提交/切换须触发元数据刷新。" }, { "id": "git-branches-remotes", @@ -383,20 +600,26 @@ "capability": "提交历史与图谱", "macos": { "evidence": [ - "macos/Sources/Lithe/Views/Git" + "macos/Sources/Lithe/Views/Git", + "macos/Sources/LitheGitModule/Services/GitGraphLayoutService.swift", + "macos/Sources/LitheGitModule/Services/GitGraphHeadOrdering.swift", + "macos/Sources/Lithe/Views/Git/GitGraphColor.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "windows": { "evidence": [ - "windows/tauri/src/features/git" + "windows/tauri/src/features/git", + "windows/tauri/src/features/git/utils/git-graph-layout.ts", + "windows/tauri/src/features/git/utils/git-graph-colors.ts", + "windows/tauri/src/features/git/utils/git-graph-layout.test.ts" ], "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Git", - "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方。" + "verification": "分页浏览提交历史、分支图谱和提交详情,确认日期、作者和文件列表一致;分支最新提交的图谱连线从提交圆点开始,不超出到圆点上方;在双端使用同一包含本地分支、远端引用、标签和合并提交的仓库,核对永久图布局、图头引用排序、合并边投影及跨越 30 行的紧凑长边;在本地分支新增提交后确认分支颜色保持稳定,且不会因共用屏幕泳道直接沿用父分支颜色。双端保留各自调色板,不要求 RGB 值一致。" }, { "id": "git-rebase-stash", @@ -627,6 +850,34 @@ "owner": "Java / Maven", "verification": "配置多个 JDK/Maven 候选,验证发现、选择、版本不匹配和错误提示。" }, + { + "id": "java-maven-project-settings", + "area": "Java", + "group": "Java/Maven", + "capability": "按项目配置 Maven 工具链、settings.xml 与本地仓库", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift", + "macos/Sources/Lithe/Services/Java/ProjectRuntimeService.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx", + "windows/tauri/src/features/maven/stores/maven.store.ts", + "windows/tauri/src-tauri/src/maven.rs", + "windows/tauri/src-tauri/src/maven/settings_xml.rs", + "windows/tauri/src/features/maven/components/maven-detected-value.test.tsx", + "windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Java / Maven", + "verification": "在 macOS 项目设置和 Windows Maven 设置页分别切换两个项目,配置 Maven、JDK、settings.xml 与本地仓库,验证保存重开、自动检测展示及项目间隔离;Windows 还需验证写盘失败后可直接重试。 Windows 自定义 settings.xml 后,仓库留空应显示该文件中的目录;验证 XML 实体和 CDATA、继承的项目 JDK/Maven、切换项目丢弃未保存草稿;冷启动 Wrapper 不应触发下载,版本探测卡住时设置窗口保持响应并在超时后清理探测进程。" + }, { "id": "java-maven-project", "area": "Java", @@ -775,6 +1026,32 @@ "owner": "Java / Spring", "verification": "从 Bean、配置和 Endpoint 结果跳转到源代码并返回,确认行列号一致。" }, + { + "id": "spring-endpoints-search", + "area": "Java", + "group": "Spring / MyBatis", + "capability": "Spring HTTP 接口筛选与源码跳转", + "macos": { + "evidence": [ + "macos/Sources/Lithe/Views/Run/SpringEndpointsView.swift", + "macos/Sources/Lithe/Application/Features/SpringFeatureModel.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx", + "windows/tauri/src/features/spring/hooks/use-spring-index.ts", + "windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx", + "windows/tauri/src/features/keymaps/commands/view-command-actions.ts" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "owner": "Java / Spring", + "verification": "在两端打开 Spring fixture,按路由、控制器、方法名和 HTTP 方法筛选,再点击接口核对源码行列;Windows 使用命令面板打开 Spring Endpoints,验证同项目刷新和失败保留结果、源码目录重命名或删除、全量重扫、扫描中切换或关闭项目、窄面板方法集合截断及超过 200 个接口的滚动与跳转。" + }, { "id": "mybatis-navigation", "area": "Java", @@ -1469,7 +1746,8 @@ "evidence": [ "macos/Sources/Lithe/LitheApp.swift", "macos/Sources/Lithe/Views/App/SettingsView.swift", - "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift" + "macos/Sources/Lithe/Views/App/ProjectRuntimeSettingsView.swift", + "macos/Tests/LitheTests/SettingsAppearanceContainerTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -1482,7 +1760,7 @@ "verificationStatus": "pending" }, "owner": "Settings", - "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;关闭后重开再检查。" + "verification": "修改应用级和项目级设置,验证持久化、迁移、重启恢复和无效值提示;macOS 保持设置窗口打开切换亮色、暗色和跟随系统模式,并改变系统外观,确认标题栏与面板背景同色、窗口按钮可用且可拖动;设置窗口应使用普通窗口层级,切换到其他应用时不置顶遮挡,关闭后重开再检查。" }, { "id": "keymap-customization", @@ -1499,14 +1777,17 @@ }, "windows": { "evidence": [ - "windows/tauri/src/features/keymaps" + "windows/tauri/src/features/keymaps", + "windows/tauri/src/features/settings/components/macos-settings-panels.tsx", + "windows/tauri/src/features/settings/components/tabs/keyboard-settings.tsx", + "windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx" ], - "implementationStatus": "partial", + "implementationStatus": "implemented", "verificationStatus": "pending" }, "owner": "Settings", - "verification": "修改快捷键、制造冲突并恢复默认,确认命令实际执行。", - "notes": "Windows 快捷键设置仍有占位交互,见 #718。" + "verification": "从设置 → 快捷键打开编辑器,按命令名称、ID 和按键搜索,修改跳转到实现的快捷键、制造冲突并恢复默认;导出再导入 keybindings.json,确认预设与自定义绑定恢复且命令实际执行。", + "notes": "Windows 已接入现有快捷键编辑器,修复 #718 的占位入口;Linux 前端回归验证不替代 Windows WebView2、原生文件对话框和快捷键实际执行验收。" }, { "id": "plugin-extension-management", diff --git a/windows/tauri/crates/project/src/git_watcher.rs b/windows/tauri/crates/project/src/git_watcher.rs index 89401d3af..a94af4859 100644 --- a/windows/tauri/crates/project/src/git_watcher.rs +++ b/windows/tauri/crates/project/src/git_watcher.rs @@ -252,6 +252,79 @@ fn is_relevant_metadata_change(context: &GitWatchContext, path: &Path) -> bool { mod tests { use super::*; + fn assert_native_linked_worktree_notification(relative_metadata_path: &str) { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::mpsc; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + struct Fixture(PathBuf); + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + struct Emitter(mpsc::Sender>); + impl GitMetadataEmitter for Emitter { + fn emit_git_metadata_change(&self, event: &GitMetadataChange) { + let _ = self.0.send(event.repository_roots.clone()); + } + } + let fixture = Fixture(std::env::temp_dir().join(format!( + "lithe-git-watch-path-{}-{}", + std::process::id(), + NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) + ))); + let repository_root = fixture.0.join("linked 工作树"); + let git_common_directory = fixture.0.join("main repo/.git"); + let git_directory = git_common_directory.join("worktrees/linked"); + std::fs::create_dir_all(&repository_root).unwrap(); + std::fs::create_dir_all(&git_directory).unwrap(); + std::fs::create_dir_all(git_common_directory.join("refs/heads")).unwrap(); + std::fs::write( + repository_root.join(".git"), + format!("gitdir: {}\n", git_directory.display()), + ) + .unwrap(); + let metadata_path = git_common_directory.join(relative_metadata_path); + std::fs::write(&metadata_path, "before\n").unwrap(); + let (sender, receiver) = mpsc::channel(); + // The watcher drops before the fixture even if the assertion fails. + let watcher = GitMetadataWatcher::new(Arc::new(Emitter(sender))); + watcher.begin("fixture-window", "fixture-lease").unwrap(); + watcher + .install( + "fixture-window", + "fixture-lease", + GitWatchContext { + repository_root: repository_root.clone(), + git_directory, + git_common_directory, + }, + ) + .unwrap(); + // Model the metadata writes made by an external commit or checkout. + // Registration is synchronous; wait for the observable native event, + // never for an assumed amount of time needed by the debounce timer. + std::fs::write(metadata_path, "after\n").unwrap(); + let roots = receiver + .recv_timeout(Duration::from_secs(5)) + .expect("native Git metadata notification should arrive within five seconds"); + assert!(roots.contains(&repository_root), "{roots:?}"); + watcher + .remove("fixture-window", Some("fixture-lease")) + .unwrap(); + } + + #[test] + fn native_linked_worktree_watch_receives_external_commit_metadata() { + assert_native_linked_worktree_notification("refs/heads/main"); + } + + #[test] + fn native_linked_worktree_watch_receives_external_checkout_metadata() { + assert_native_linked_worktree_notification("worktrees/linked/HEAD"); + } + #[test] fn shared_refs_and_worktree_registration_changes_refresh_all_related_worktrees() { let context = GitWatchContext { diff --git a/windows/tauri/src-tauri/Cargo.lock b/windows/tauri/src-tauri/Cargo.lock index fc6a9b48a..fabd89d26 100644 --- a/windows/tauri/src-tauri/Cargo.lock +++ b/windows/tauri/src-tauri/Cargo.lock @@ -8,6 +8,56 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "agent-client-protocol" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13f780597b2c88ab83d02509bcc3e86366751f86fa12114005e35c55ed691763" +dependencies = [ + "agent-client-protocol-derive", + "agent-client-protocol-schema", + "async-io", + "async-process", + "blocking", + "futures", + "futures-concurrency", + "rustc-hash 2.1.3", + "rustix 1.1.4", + "schemars 1.2.2", + "serde", + "serde_json", + "shell-words", + "tracing", + "uuid", + "windows-sys 0.61.2", +] + +[[package]] +name = "agent-client-protocol-derive" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4893bfb11e0e193aa5a64611a126ee87567acb1879556519c902722899229de" +dependencies = [ + "quote", + "syn 3.0.3", +] + +[[package]] +name = "agent-client-protocol-schema" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d595a79e1665d02c91c7dd1bedd118501e75cc0a3d5b1651b5150ec4c5c1e0d" +dependencies = [ + "anyhow", + "derive_more", + "schemars 1.2.2", + "serde", + "serde_json", + "serde_with", + "strum", + "tracing", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -140,7 +190,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix", + "rustix 1.1.4", "slab", "windows-sys 0.61.2", ] @@ -171,7 +221,7 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -197,7 +247,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix", + "rustix 1.1.4", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -273,6 +323,29 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.69.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools", + "lazy_static", + "lazycell", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex 1.3.0", + "syn 2.0.119", + "which", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -480,7 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -489,6 +562,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfb" version = "0.7.3" @@ -553,6 +635,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -585,7 +678,7 @@ dependencies = [ "jetscii", "phf", "phf_codegen", - "rustc-hash", + "rustc-hash 2.1.3", "smallvec", "typed-arena", ] @@ -625,6 +718,15 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cookie" version = "0.18.2" @@ -959,10 +1061,12 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ + "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.119", + "unicode-xid", ] [[package]] @@ -1436,6 +1540,21 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.34" @@ -1446,6 +1565,19 @@ dependencies = [ "futures-sink", ] +[[package]] +name = "futures-concurrency" +version = "7.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" +dependencies = [ + "fixedbitset", + "futures-core", + "futures-lite", + "pin-project", + "smallvec", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -1511,6 +1643,7 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ + "futures-channel", "futures-core", "futures-io", "futures-macro", @@ -1636,7 +1769,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix", + "rustix 1.1.4", "windows-link 0.2.1", ] @@ -1908,6 +2041,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -2270,6 +2412,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2488,6 +2639,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kill_tree" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3879339076ac4da142cc852d91693462927cbc99773b5ea422e4834e68c4ff2" +dependencies = [ + "bindgen", + "nix 0.27.1", + "tokio", + "tracing", + "windows 0.52.0", +] + [[package]] name = "kqueue" version = "1.2.1" @@ -2517,6 +2681,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "libappindicator" version = "0.9.0" @@ -2537,7 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" dependencies = [ "gtk-sys", - "libloading", + "libloading 0.7.4", "once_cell", ] @@ -2566,6 +2736,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.16" @@ -2581,6 +2761,12 @@ dependencies = [ "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2593,6 +2779,22 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lithe-agent-host" +version = "0.1.0" +dependencies = [ + "agent-client-protocol", + "futures", + "kill_tree", + "libc", + "serde", + "serde_json", + "tokio", + "tokio-util", + "url", + "uuid", +] + [[package]] name = "lithe-core" version = "0.1.0" @@ -2600,6 +2802,7 @@ dependencies = [ "ammonia", "base64 0.22.1", "comrak", + "lithe-agent-host", "lithe-git-host", "quick-xml 0.37.5", "rand 0.8.7", @@ -2670,7 +2873,9 @@ dependencies = [ "lithe-git-host", "lithe-project", "lithe-terminal", + "quick-xml 0.37.5", "regex", + "same-file", "serde", "serde_json", "sha2", @@ -2772,6 +2977,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "minisign-verify" version = "0.2.5" @@ -2861,6 +3072,17 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "libc", +] + [[package]] name = "nix" version = "0.28.0" @@ -2885,6 +3107,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nom" version = "8.0.0" @@ -3459,6 +3691,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3552,7 +3804,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3622,6 +3874,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -3741,7 +4003,7 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", "thiserror 2.0.20", @@ -3762,7 +4024,7 @@ dependencies = [ "rand 0.10.2", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", @@ -4121,6 +4383,12 @@ dependencies = [ "ordered-multimap", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -4136,6 +4404,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -4145,7 +4426,7 @@ dependencies = [ "bitflags 2.13.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -4261,7 +4542,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" dependencies = [ "dyn-clone", "indexmap 1.9.3", - "schemars_derive", + "schemars_derive 0.8.22", "serde", "serde_json", "url", @@ -4288,6 +4569,7 @@ checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", + "schemars_derive 1.2.2", "serde", "serde_json", ] @@ -4300,10 +4582,22 @@ checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.29.1", "syn 2.0.119", ] +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.30.0", + "syn 3.0.3", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -4347,7 +4641,7 @@ dependencies = [ "phf", "phf_codegen", "precomputed-hash", - "rustc-hash", + "rustc-hash 2.1.3", "servo_arc", "smallvec", ] @@ -4415,6 +4709,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "serde_json" version = "1.0.151" @@ -4607,6 +4912,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -4810,6 +5121,27 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5496,7 +5828,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -5636,7 +5968,9 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", @@ -5671,6 +6005,7 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "libc", "pin-project-lite", @@ -5932,7 +6267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", - "nom", + "nom 8.0.0", "petgraph", ] @@ -6033,6 +6368,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -6230,7 +6571,7 @@ checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs", - "rustix", + "rustix 1.1.4", "smallvec", "wayland-sys", ] @@ -6242,7 +6583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.1", - "rustix", + "rustix 1.1.4", "wayland-backend", "wayland-scanner", ] @@ -6428,6 +6769,18 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6474,6 +6827,16 @@ dependencies = [ "windows-version", ] +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.57.0" @@ -6506,6 +6869,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.57.0" @@ -7061,7 +7433,7 @@ dependencies = [ "libc", "log", "os_pipe", - "rustix", + "rustix 1.1.4", "thiserror 2.0.20", "tree_magic_mini", "wayland-backend", @@ -7148,7 +7520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix", + "rustix 1.1.4", "x11rb-protocol", ] @@ -7165,7 +7537,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -7213,7 +7585,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tracing", diff --git a/windows/tauri/src-tauri/Cargo.toml b/windows/tauri/src-tauri/Cargo.toml index 838ab63d6..d6ddba338 100644 --- a/windows/tauri/src-tauri/Cargo.toml +++ b/windows/tauri/src-tauri/Cargo.toml @@ -28,6 +28,8 @@ keyring = { version = "3.6.3", features = ["windows-native"] } serde = { version = "1", features = ["derive"] } serde_json = "1" regex = "1" +quick-xml = "0.37" +same-file = "1.0.6" sha2 = "0.10" sysinfo = "0.32" zip = { version = "2.4", default-features = false, features = ["deflate"] } diff --git a/windows/tauri/src-tauri/src/debug.rs b/windows/tauri/src-tauri/src/debug.rs index 2d7ae3aaa..480c535a5 100644 --- a/windows/tauri/src-tauri/src/debug.rs +++ b/windows/tauri/src-tauri/src/debug.rs @@ -1558,10 +1558,7 @@ mod tests { ) .unwrap(); assert_eq!( - received - .recv_timeout(Duration::from_secs(1)) - .unwrap() - .1["message"]["type"], + received.recv_timeout(Duration::from_secs(1)).unwrap().1["message"]["type"], "initialized" ); } @@ -1927,7 +1924,10 @@ mod tests { struct FailingWriter; impl Write for FailingWriter { fn write(&mut self, _buffer: &[u8]) -> std::io::Result { - Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "closed")) + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "closed", + )) } fn flush(&mut self) -> std::io::Result<()> { diff --git a/windows/tauri/src-tauri/src/diagnostics.rs b/windows/tauri/src-tauri/src/diagnostics.rs index 100ed075f..28e71797a 100644 --- a/windows/tauri/src-tauri/src/diagnostics.rs +++ b/windows/tauri/src-tauri/src/diagnostics.rs @@ -388,7 +388,8 @@ mod tests { // redaction provably shrinks the content; that keeps the raw-size vs // redacted-size distinction below meaningful. (A short secret like // `hunter2` would grow into `` and invert the comparison.) - let raw = "token=0123456789abcdef0123456789abcdef0123 trailing text to keep the line long\n"; + let raw = + "token=0123456789abcdef0123456789abcdef0123 trailing text to keep the line long\n"; fs::write(directory.join(planted_name), raw).unwrap(); let planted_relative_path = format!("logs/{planted_name}"); diff --git a/windows/tauri/src-tauri/src/host.rs b/windows/tauri/src-tauri/src/host.rs index 396584847..b7ab90532 100644 --- a/windows/tauri/src-tauri/src/host.rs +++ b/windows/tauri/src-tauri/src/host.rs @@ -3,8 +3,8 @@ use serde_json::Value; use std::fs::{self, File}; use std::io::{self, Read, Write}; use std::path::{Path, PathBuf}; -use std::sync::Mutex; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, Theme, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_opener::OpenerExt; @@ -258,6 +258,32 @@ fn copy_path(source: &std::path::Path, destination: &std::path::Path) -> Result< #[tauri::command] pub async fn create_app_window(app: AppHandle, request: Option) -> Result { let label = format!("workspace-{}", WINDOW_ID.fetch_add(1, Ordering::Relaxed)); + let project_path = request + .as_ref() + .filter(|request| { + request.get("remoteConnectionId").is_none() + && (request.get("isDirectory").and_then(Value::as_bool) == Some(true) + || request.get("type").and_then(Value::as_str) == Some("directory")) + }) + .and_then(|request| request.get("path").and_then(Value::as_str)) + .filter(|path| !path.contains("://")); + let identity = project_path + .map(|path| { + crate::project_window_registry::ProjectWindowRegistry::identity(Path::new(path)) + }) + .transpose()?; + let state = app.state::(); + // Serialize lookup + reservation + build, including concurrent IPC callers. + // The async mutex never blocks the UI event loop during Webview construction. + let mut registry = state.0.lock().await; + registry.retain_windows(|owner| app.get_webview_window(owner).is_some()); + if let Some(identity) = identity { + let owner = registry.claim(identity, &label, None); + if owner.label != label { + crate::project_windows::focus_project(&app, &owner)?; + return Ok(owner.label); + } + } let mut query = url::form_urlencoded::Serializer::new(String::new()); if let Some(request) = request.and_then(|value| value.as_object().cloned()) { query.append_pair("target", "open"); @@ -292,24 +318,34 @@ pub async fn create_app_window(app: AppHandle, request: Option) -> Result } else { format!("index.html?{query}") }; - let window = WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(path.into())) - .title("Lithe") - .decorations(false) - .inner_size(1280.0, 800.0) - .min_inner_size(720.0, 480.0) - .icon(WINDOW_TASKBAR_ICON) - .map_err(|error| error.to_string())? - .build() - .map_err(|error| error.to_string())?; - apply_window_taskbar_icon(&window); - Ok(label) + let result = (|| { + WebviewWindowBuilder::new(&app, &label, WebviewUrl::App(path.into())) + .title("Lithe") + .decorations(false) + .inner_size(1280.0, 800.0) + .min_inner_size(720.0, 480.0) + .icon(WINDOW_TASKBAR_ICON) + .map_err(|error| error.to_string())? + .build() + .map_err(|error| error.to_string()) + })(); + match result { + Ok(window) => { + apply_window_taskbar_icon(&window); + Ok(label) + } + Err(error) => { + registry.release(&label, None); + Err(error) + } + } } #[cfg(test)] mod tests { use super::{ - WINDOW_TASKBAR_ICON, cli_payloads, copy_path, create_app_window, read_bounded, - read_local_file_bounded, unique_destination, + cli_payloads, copy_path, create_app_window, read_bounded, read_local_file_bounded, + unique_destination, WINDOW_TASKBAR_ICON, }; use std::fs; use std::future::Future; diff --git a/windows/tauri/src-tauri/src/main.rs b/windows/tauri/src-tauri/src/main.rs index b60a3bc30..260c7f9ba 100644 --- a/windows/tauri/src-tauri/src/main.rs +++ b/windows/tauri/src-tauri/src/main.rs @@ -13,6 +13,8 @@ mod lsp; mod maven; mod memory; mod platform; +mod project_window_registry; +mod project_windows; mod run; mod secure_storage; mod terminal; @@ -87,6 +89,7 @@ fn main() { std::env::args().skip(1), )); app.manage(host::FileClipboard::default()); + app.manage(project_windows::ProjectWindows::default()); app.manage(run::RunProcessManager::default()); app.manage(debug::DebugAdapterManager::default()); run::cleanup_legacy_appdata(app.handle()); @@ -98,6 +101,7 @@ fn main() { }) .on_window_event(|window, event| { if matches!(event, tauri::WindowEvent::Destroyed) { + project_windows::release_window(window.app_handle(), window.label().to_owned()); if let Some(watcher) = window.try_state::>() { if let Err(error) = watcher.remove_owner(window.label()) { eprintln!("Could not release document watches: {error}"); @@ -176,6 +180,9 @@ fn main() { host::clipboard_paste, host::clipboard_clear, host::create_app_window, + project_windows::claim_project_window, + project_windows::release_project_window, + project_windows::release_pending_project_window, lsp::lsp_resolve_java_launch, lsp::lsp_rebuild_java_index, language_tools::get_tool_path, @@ -183,6 +190,7 @@ fn main() { language_tools::cancel_language_tool_install, language_tools::uninstall_language_tools, maven::maven_load_configuration, + maven::maven_resolve_effective_configuration, maven::maven_write_configuration, maven::maven_create_dependency_output, maven::maven_remove_dependency_output, diff --git a/windows/tauri/src-tauri/src/maven.rs b/windows/tauri/src-tauri/src/maven.rs index 081d032a8..df873dc96 100644 --- a/windows/tauri/src-tauri/src/maven.rs +++ b/windows/tauri/src-tauri/src/maven.rs @@ -9,13 +9,17 @@ //! is cleared at startup, so a crash cannot leave trees behind or feed an old //! tree to a later session. -use crate::run::atomic_write; +use crate::run::{atomic_write, resolve_java_home, resolve_maven_executable}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager}; +mod settings_xml; + +use settings_xml::parse_local_repository; + const MAVEN_CONFIGURATION_VERSION: u32 = 1; /// Cache subdirectory holding one dependency-tree file per active session. const DEPENDENCY_TREE_DIRECTORY: &str = "maven-dependency-trees"; @@ -91,6 +95,93 @@ pub fn maven_write_configuration( ) } +/// Arguments for resolving the configuration a Maven launch would use. Every +/// value mirrors one of the machine-local fields; empty means "detect it". +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolveEffectiveConfigurationArgs { + pub root: PathBuf, + /// Reactor path relative to the workspace root; empty means the root itself. + #[serde(default)] + pub working_directory: String, + #[serde(default)] + pub settings_path: String, + #[serde(default)] + pub local_repository_path: String, + #[serde(default)] + pub maven_executable_path: String, + #[serde(default)] + pub java_home_path: String, +} + +/// The values a Maven launch would actually use, alongside the values detection +/// found when every saved override is ignored. `None` means detection found +/// nothing for that field. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MavenEffectiveConfiguration { + pub settings_path: Option, + pub local_repository_path: Option, + pub maven_executable_path: Option, + pub java_home_path: Option, + pub detected_settings_path: Option, + pub detected_local_repository_path: Option, + pub detected_maven_executable_path: Option, + pub detected_java_home_path: Option, +} + +/// Resolves the effective Maven configuration for the saved settings, so the +/// configuration surfaces can show what their empty fields resolve to. +/// +/// The Maven executable and JDK come from the same resolution the launch path +/// uses, which keeps the reported values identical to the launched ones. +#[tauri::command] +pub async fn maven_resolve_effective_configuration( + args: ResolveEffectiveConfigurationArgs, +) -> Result { + // JDK probing and filesystem reads must not block Tauri's event loop. + tauri::async_runtime::spawn_blocking(move || resolve_effective_configuration(args)) + .await + .map_err(|error| format!("Maven configuration detection failed: {error}"))? +} + +fn resolve_effective_configuration( + args: ResolveEffectiveConfigurationArgs, +) -> Result { + let root = existing_directory(&args.root)?; + let working_directory = match args.working_directory.trim() { + "" => root.clone(), + reactor => root.join(reactor), + }; + // Detection is resolved once with empty overrides. A saved path is resolved + // only when the user set one, so the automatic case does not probe twice. + let detected_maven = resolve_maven_executable(&root, &working_directory, "").ok(); + let detected_java = resolve_java_home(&root, "").ok().flatten(); + let selected_maven = if args.maven_executable_path.trim().is_empty() { + detected_maven.clone() + } else { + resolve_maven_executable(&root, &working_directory, &args.maven_executable_path).ok() + }; + let selected_java = if args.java_home_path.trim().is_empty() { + detected_java.clone() + } else { + resolve_java_home(&root, &args.java_home_path) + .ok() + .flatten() + }; + Ok(assemble_effective_configuration( + &args.settings_path, + &args.local_repository_path, + &args.maven_executable_path, + &args.java_home_path, + detected_maven, + detected_java, + selected_maven, + selected_java, + user_home_directory().as_deref(), + )) +} + /// Returns a fresh path for the tree one dependency session writes. /// /// A file left by an earlier session with the same ID is removed first, so @@ -164,6 +255,157 @@ fn clear_dependency_output_directory(directory: &Path) -> std::io::Result<()> { } } +/// Combines saved overrides with already resolved executables. +/// +/// `detected_*` arguments are what the machine finds with every override blank. +/// `selected_*` arguments are the executables a launch would use, which equal +/// the detected ones when the corresponding override is blank. +fn assemble_effective_configuration( + configured_settings: &str, + configured_repository: &str, + configured_maven: &str, + configured_java: &str, + detected_maven: Option, + detected_java: Option, + selected_maven: Option, + selected_java: Option, + home: Option<&Path>, +) -> MavenEffectiveConfiguration { + let maven_executable_path = if configured_maven.trim().is_empty() { + detected_maven.clone() + } else { + selected_maven + }; + let java_home_path = if configured_java.trim().is_empty() { + detected_java.clone() + } else { + selected_java + }; + let detected_settings_path = effective_settings_path("", home, detected_maven.as_deref()); + let settings_path = + effective_settings_path(configured_settings, home, maven_executable_path.as_deref()); + let detected_global_settings = effective_settings_path("", None, detected_maven.as_deref()); + let selected_global_settings = + effective_settings_path("", None, maven_executable_path.as_deref()); + let detected_local_repository_path = effective_local_repository_path( + "", + detected_settings_path.as_deref(), + detected_global_settings.as_deref(), + home, + ); + let local_repository_path = effective_local_repository_path( + configured_repository, + settings_path.as_deref(), + selected_global_settings.as_deref(), + home, + ); + MavenEffectiveConfiguration { + settings_path, + local_repository_path, + maven_executable_path, + java_home_path, + detected_settings_path, + detected_local_repository_path, + detected_maven_executable_path: detected_maven, + detected_java_home_path: detected_java, + } +} + +/// The current user's home directory, which owns the Maven user-level defaults +/// (`~/.m2/settings.xml` and `~/.m2/repository`). +fn user_home_directory() -> Option { + std::env::var("USERPROFILE") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) +} + +/// The settings file Maven itself would read: the configured path, then the +/// user-level default, then the detected installation's global settings. +/// +/// A configured path is reported even when the file is missing, because that is +/// what the launch would ask for and the surface must not hide the user's input. +fn effective_settings_path( + configured: &str, + home: Option<&Path>, + maven_executable_path: Option<&str>, +) -> Option { + let configured = configured.trim(); + if !configured.is_empty() { + return Some(configured.to_string()); + } + let mut candidates = Vec::new(); + if let Some(home) = home { + candidates.push(home.join(".m2").join("settings.xml")); + } + if let Some(executable) = maven_executable_path { + // `/bin/mvn.cmd` -> `/conf/settings.xml`. + // A wrapper lives in the project, not in a Maven installation's bin directory. + let executable = Path::new(executable); + if let Some(installation) = executable + .parent() + .filter(|parent| { + parent + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("bin")) + }) + .filter(|_| { + executable + .file_stem() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.eq_ignore_ascii_case("mvn")) + }) + .and_then(Path::parent) + { + candidates.push(installation.join("conf").join("settings.xml")); + } + } + candidates + .into_iter() + .find(|candidate| candidate.is_file()) + .map(|path| path.to_string_lossy().into_owned()) +} + +/// The local repository Maven would use: the configured path, then the +/// `` of the user (or configured) settings, then the +/// installation settings, then the `~/.m2/repository` default. +fn effective_local_repository_path( + configured: &str, + settings_path: Option<&str>, + global_settings_path: Option<&str>, + home: Option<&Path>, +) -> Option { + let configured = configured.trim(); + if !configured.is_empty() { + return Some(configured.to_string()); + } + for settings_path in [settings_path, global_settings_path].into_iter().flatten() { + let contents = fs::read_to_string(settings_path).unwrap_or_default(); + if let Some(repository) = parse_local_repository(&contents) { + return Some(expand_user_home(&repository, home)); + } + } + home.map(|home| { + home.join(".m2") + .join("repository") + .to_string_lossy() + .into_owned() + }) +} + +/// Maven's default settings use `${user.home}` for the repository. Other +/// unresolved properties stay literal; inventing a path for them would hide +/// the value the file actually contains. +fn expand_user_home(value: &str, home: Option<&Path>) -> String { + const TOKEN: &str = "${user.home}"; + match home { + Some(home) if value.contains(TOKEN) => value.replace(TOKEN, &home.to_string_lossy()), + _ => value.to_string(), + } +} + fn validate_versions( portable: Option<&MavenPortableConfiguration>, local: Option<&MavenLocalConfiguration>, @@ -263,6 +505,260 @@ mod tests { path } + #[test] + fn parses_the_local_repository_element_without_commented_examples() { + let settings = "\ + + + ${user.home}/.m2/repository +"; + + assert_eq!( + parse_local_repository(settings).as_deref(), + Some("${user.home}/.m2/repository") + ); + assert_eq!( + parse_local_repository( + "" + ), + None + ); + } + + #[test] + fn settings_path_prefers_configured_then_user_then_installation() { + let home = temp_directory(); + let installation = temp_directory(); + let configured = home.join("configured.xml"); + let executable = installation + .join("bin") + .join("mvn.cmd") + .to_string_lossy() + .into_owned(); + let user_settings = home.join(".m2").join("settings.xml"); + let global_settings = installation.join("conf").join("settings.xml"); + fs::create_dir_all(global_settings.parent().expect("global settings parent")) + .expect("conf directory"); + + assert_eq!( + effective_settings_path( + &configured.to_string_lossy(), + Some(&home), + Some(&executable) + ) + .as_deref(), + Some(configured.to_string_lossy().as_ref()) + ); + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)), + None + ); + + fs::create_dir_all(user_settings.parent().expect("user settings parent")).expect("user m2"); + fs::write(&user_settings, "").expect("write user settings"); + fs::write(&global_settings, "").expect("write global settings"); + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)).as_deref(), + Some(user_settings.to_string_lossy().as_ref()) + ); + + fs::remove_file(&user_settings).expect("remove user settings"); + assert_eq!( + effective_settings_path("", Some(&home), Some(&executable)).as_deref(), + Some(global_settings.to_string_lossy().as_ref()) + ); + fs::remove_dir_all(home).ok(); + fs::remove_dir_all(installation).ok(); + } + + #[test] + fn wrapper_does_not_adopt_an_unrelated_global_settings_file() { + let directory = temp_directory(); + struct Cleanup(PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).ok(); + } + } + let _cleanup = Cleanup(directory.clone()); + fs::create_dir_all(directory.join("conf")).unwrap(); + fs::write(directory.join("conf/settings.xml"), "").unwrap(); + // Even a project named `bin` does not make its wrapper an installation. + for project in ["project", "bin"] { + let wrapper = directory.join(project).join("mvnw.cmd"); + assert_eq!( + effective_settings_path("", None, Some(&wrapper.to_string_lossy())), + None + ); + } + } + + #[test] + fn repository_falls_back_to_installation_when_user_settings_omit_it() { + let home = temp_directory(); + let installation = temp_directory(); + struct Cleanup(PathBuf, PathBuf); + impl Drop for Cleanup { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).ok(); + fs::remove_dir_all(&self.1).ok(); + } + } + let _cleanup = Cleanup(home.clone(), installation.clone()); + let user_settings = home.join(".m2").join("settings.xml"); + let global_settings = installation.join("conf").join("settings.xml"); + fs::create_dir_all(user_settings.parent().expect("user settings parent")).expect("user m2"); + fs::create_dir_all(global_settings.parent().expect("global settings parent")) + .expect("conf directory"); + fs::write(&user_settings, "").expect("write user settings"); + fs::write( + &global_settings, + "D:\\global-repo", + ) + .expect("write global settings"); + let executable = installation + .join("bin") + .join("mvn.cmd") + .to_string_lossy() + .into_owned(); + + let resolved = assemble_effective_configuration( + "", + "", + "", + "", + Some(executable.clone()), + None, + Some(executable.clone()), + None, + Some(&home), + ); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some("D:\\global-repo") + ); + assert_eq!( + resolved.detected_local_repository_path.as_deref(), + Some("D:\\global-repo") + ); + + fs::write( + &user_settings, + "D:\\user-repo", + ) + .expect("update user settings"); + let resolved = assemble_effective_configuration( + "", + "", + "", + "", + Some(executable.clone()), + None, + Some(executable), + None, + Some(&home), + ); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some("D:\\user-repo") + ); + } + + #[test] + fn detected_paths_stay_visible_when_overrides_are_configured() { + let home = temp_directory(); + fs::create_dir_all(home.join(".m2")).expect("user m2"); + let user_settings = home.join(".m2").join("settings.xml"); + fs::write( + &user_settings, + "C:\\from-settings", + ) + .expect("write user settings"); + + let resolved = assemble_effective_configuration( + "D:\\custom-settings.xml", + "D:\\custom-repo", + "D:\\custom-maven", + "D:\\custom-jdk", + Some("D:\\detected\\mvn.cmd".into()), + Some("D:\\detected-jdk".into()), + Some("D:\\custom-maven\\bin\\mvn.cmd".into()), + Some("D:\\custom-jdk".into()), + Some(&home), + ); + + assert_eq!( + resolved.maven_executable_path.as_deref(), + Some("D:\\custom-maven\\bin\\mvn.cmd") + ); + assert_eq!( + resolved.detected_maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!(resolved.java_home_path.as_deref(), Some("D:\\custom-jdk")); + assert_eq!( + resolved.detected_java_home_path.as_deref(), + Some("D:\\detected-jdk") + ); + assert_eq!( + resolved.settings_path.as_deref(), + Some("D:\\custom-settings.xml") + ); + assert_eq!( + resolved.detected_settings_path.as_deref(), + Some(user_settings.to_string_lossy().as_ref()) + ); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some("D:\\custom-repo") + ); + assert_eq!( + resolved.detected_local_repository_path.as_deref(), + Some("C:\\from-settings") + ); + fs::remove_dir_all(home).ok(); + } + + #[test] + fn blank_configuration_uses_the_detected_values() { + let home = temp_directory(); + let resolved = assemble_effective_configuration( + "", + "", + "", + "", + Some("D:\\detected\\mvn.cmd".into()), + None, + Some("D:\\detected\\mvn.cmd".into()), + None, + Some(&home), + ); + let repository = home.join(".m2").join("repository"); + + assert_eq!( + resolved.maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!( + resolved.detected_maven_executable_path.as_deref(), + Some("D:\\detected\\mvn.cmd") + ); + assert_eq!(resolved.java_home_path, None); + assert_eq!(resolved.detected_java_home_path, None); + assert_eq!( + resolved.local_repository_path.as_deref(), + Some(repository.to_string_lossy().as_ref()) + ); + assert_eq!( + resolved.detected_local_repository_path, + resolved.local_repository_path + ); + fs::remove_dir_all(home).ok(); + } + #[test] fn dependency_outputs_are_fresh_per_session_and_removed_on_request() { let cache = temp_directory(); diff --git a/windows/tauri/src-tauri/src/maven/settings_xml.rs b/windows/tauri/src-tauri/src/maven/settings_xml.rs new file mode 100644 index 000000000..bcc469ec5 --- /dev/null +++ b/windows/tauri/src-tauri/src/maven/settings_xml.rs @@ -0,0 +1,140 @@ +//! Reads the optional local repository from a Maven settings document. +//! +//! Maven still owns effective settings and interpolation. This read-only hint +//! uses the same XML library as Core instead of interpreting XML as tag text. + +use quick_xml::{events::Event, Reader}; + +pub(super) fn parse_local_repository(settings: &str) -> Option { + let mut reader = Reader::from_str(settings); + reader.config_mut().expand_empty_elements = true; + reader.config_mut().check_comments = true; + let mut depth: usize = 0; + let mut root_seen = false; + let mut collecting = false; + let mut repository: Option = None; + + // Read through EOF: a valid-looking path before an incomplete closing tag + // must not be presented as a valid settings value. + loop { + match reader.read_event().ok()? { + Event::Start(element) => { + for attribute in element.attributes() { + attribute.ok()?; + } + if depth == 0 { + if root_seen || element.local_name().as_ref() != b"settings" { + return None; + } + root_seen = true; + } else if collecting { + return None; + } else if depth == 1 && element.local_name().as_ref() == b"localRepository" { + if repository.is_some() { + return None; + } + repository = Some(String::new()); + collecting = true; + } + depth += 1; + } + Event::End(_) => { + depth = depth.checked_sub(1)?; + if depth == 1 { + collecting = false; + } + } + Event::Text(text) => { + let text = text.unescape().ok()?; + if collecting { + repository.as_mut()?.push_str(&text); + } else if depth == 0 && !text.trim().is_empty() { + return None; + } + } + Event::CData(text) => { + if depth == 0 { + return None; + } + if collecting { + repository.as_mut()?.push_str(&text.decode().ok()?); + } + } + // Do not expand external entities or attempt to read a DTD. + Event::DocType(_) => return None, + Event::Eof => break, + _ => {} + } + } + if depth != 0 || !root_seen { + return None; + } + repository + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::parse_local_repository; + + #[test] + fn decodes_xml_entities_in_repository_paths() { + assert_eq!( + parse_local_repository( + " D:/R&D/仓库 " + ), + Some("D:/R&D/仓库".into()) + ); + } + + #[test] + fn reads_cdata_and_adjacent_text_without_losing_spaces() { + assert_eq!( + parse_local_repository( + " /repo" + ), + Some("D:/R&D /repo".into()) + ); + } + + #[test] + fn reads_namespaced_settings_and_ignores_comment_examples() { + assert_eq!( + parse_local_repository( + r#" + + ${user.home}/.m2/team"# + ), + Some("${user.home}/.m2/team".into()) + ); + } + + #[test] + fn only_reads_the_direct_settings_child() { + assert_eq!(parse_local_repository( + "D:/other" + ), None); + assert_eq!( + parse_local_repository(""), + None + ); + } + + #[test] + fn rejects_incomplete_or_malformed_documents() { + for xml in [ + "D:/repo", + "D:/repo", + "D:/repo", + "D:/repoD:/repo", + "D:/repotrailing text", + "D:/&unknown;", + "D:/repo", + "D:/repoD:/other", + ] { + assert_eq!(parse_local_repository(xml), None, "accepted {xml}"); + } + } +} diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 379160e7f..fbb962815 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -43,7 +43,11 @@ pub async fn platform_invoke( | "git.configureIdentity" ) || matches!( command.as_str(), - "git_add_remote" | "git_remove_remote" | "git_create_tag" | "git_delete_tag" | "git.command" + "git_add_remote" + | "git_remove_remote" + | "git_create_tag" + | "git_delete_tag" + | "git.command" ); // Observe every Git request at the shared boundary. Core suppresses its // parser-only probes, so new operation entry points cannot miss the console. @@ -97,7 +101,9 @@ pub async fn platform_invoke( } fn observes_git_execution(command: &str) -> bool { - command.starts_with("git.") && command != "git.authRespond" && command != "git.consolePresentation" + command.starts_with("git.") + && command != "git.authRespond" + && command != "git.consolePresentation" } fn core_response( diff --git a/windows/tauri/src-tauri/src/project_window_registry.rs b/windows/tauri/src-tauri/src/project_window_registry.rs new file mode 100644 index 000000000..981fdb9fe --- /dev/null +++ b/windows/tauri/src-tauri/src/project_window_registry.rs @@ -0,0 +1,222 @@ +//! Native directory identities and their owning workspace windows. + +use same_file::Handle; +use serde::Serialize; +use std::collections::HashMap; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectWindowOwner { + pub label: String, + // None reserves a newly built window until its frontend opens the project. + pub workspace_id: Option, +} + +#[derive(Default)] +pub struct ProjectWindowRegistry { + owners: HashMap, +} + +impl ProjectWindowRegistry { + pub fn identity(path: &Path) -> Result { + if !path.is_dir() { + return Err("Project directory is unavailable".into()); + } + // Keep the native handle alive: file IDs may be reused after it closes. + // This also preserves case-sensitive directories and directory aliases. + Handle::from_path(path) + .map_err(|error| format!("Cannot identify project directory: {error}")) + } + + pub fn claim( + &mut self, + identity: Handle, + label: &str, + workspace_id: Option<&str>, + ) -> ProjectWindowOwner { + let owner = self + .owners + .entry(identity) + .or_insert_with(|| ProjectWindowOwner { + label: label.to_owned(), + workspace_id: workspace_id.map(str::to_owned), + }); + if owner.label == label && owner.workspace_id.is_none() { + owner.workspace_id = workspace_id.map(str::to_owned); + } + owner.clone() + } + + pub fn release(&mut self, label: &str, workspace_id: Option<&str>) { + self.owners.retain(|_, owner| { + owner.label != label + || workspace_id.is_some_and(|id| owner.workspace_id.as_deref() != Some(id)) + }); + } + + pub fn release_pending(&mut self, label: &str) { + self.owners + .retain(|_, owner| owner.label != label || owner.workspace_id.is_some()); + } + + pub fn retain_windows(&mut self, exists: impl Fn(&str) -> bool) { + self.owners.retain(|_, owner| exists(&owner.label)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ROOT: AtomicU64 = AtomicU64::new(0); + struct TestDirectory(std::path::PathBuf); + impl TestDirectory { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "lithe-project-window-{}-{}", + std::process::id(), + NEXT_ROOT.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).unwrap(); + Self(path) + } + fn identity(&self) -> Handle { + ProjectWindowRegistry::identity(&self.0).unwrap() + } + } + impl Drop for TestDirectory { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.0) { + eprintln!("Could not remove project-window test directory: {error}"); + } + } + } + + #[test] + fn reserves_before_frontend_registration_and_reuses_original_owner() { + let root = TestDirectory::new(); + let mut registry = ProjectWindowRegistry::default(); + let pending = registry.claim(root.identity(), "workspace-1", None); + assert_eq!( + registry.claim(root.identity(), "workspace-2", None), + pending + ); + let opened = registry.claim(root.identity(), "workspace-1", Some("project-a")); + assert_eq!(opened.workspace_id.as_deref(), Some("project-a")); + assert_eq!( + registry.claim(root.identity(), "main", Some("alias")), + opened + ); + } + + #[test] + fn release_only_removes_the_matching_window_and_workspace() { + let a = TestDirectory::new(); + let b = TestDirectory::new(); + let mut registry = ProjectWindowRegistry::default(); + let owner_a = registry.claim(a.identity(), "main", Some("a")); + let owner_b = registry.claim(b.identity(), "main", Some("b")); + registry.release("other", Some("a")); + assert_eq!(registry.claim(a.identity(), "other", Some("a")), owner_a); + registry.release("main", Some("a")); + assert_eq!( + registry.claim(a.identity(), "other", Some("a")).label, + "other" + ); + assert_eq!(registry.claim(b.identity(), "other", Some("b")), owner_b); + registry.release("main", None); + assert_eq!( + registry.claim(b.identity(), "other", Some("b")).label, + "other" + ); + } + + #[test] + fn failed_creation_and_stale_windows_do_not_keep_reservations() { + let root = TestDirectory::new(); + let mut registry = ProjectWindowRegistry::default(); + registry.claim(root.identity(), "failed", None); + registry.release("failed", None); + assert_eq!( + registry.claim(root.identity(), "closed", None).label, + "closed" + ); + registry.retain_windows(|label| label != "closed"); + assert_eq!(registry.claim(root.identity(), "new", None).label, "new"); + } + + #[test] + fn failed_initial_open_releases_only_the_unregistered_reservation() { + let pending = TestDirectory::new(); + let opened = TestDirectory::new(); + let mut registry = ProjectWindowRegistry::default(); + registry.claim(pending.identity(), "main", None); + let owner = registry.claim(opened.identity(), "main", Some("opened")); + registry.release_pending("main"); + assert_eq!( + registry.claim(pending.identity(), "retry", None).label, + "retry" + ); + assert_eq!(registry.claim(opened.identity(), "retry", None), owner); + } + + #[test] + fn equivalent_paths_match_but_distinct_directories_do_not() { + let root = TestDirectory::new(); + let child = root.0.join("Child"); + fs::create_dir(&child).unwrap(); + assert_eq!( + root.identity(), + ProjectWindowRegistry::identity(&child.join("..")).unwrap() + ); + assert_ne!( + root.identity(), + ProjectWindowRegistry::identity(&child).unwrap() + ); + #[cfg(windows)] + { + let alternate_case = root.0.join("child"); + // Windows directories can opt into case-sensitive lookup. + if alternate_case.is_dir() { + assert_eq!( + ProjectWindowRegistry::identity(&child).unwrap(), + ProjectWindowRegistry::identity(&alternate_case).unwrap() + ); + } else { + assert!(ProjectWindowRegistry::identity(&alternate_case).is_err()); + } + } + } + + #[cfg(unix)] + #[test] + fn follows_directory_links_without_folding_case_sensitive_names() { + let root = TestDirectory::new(); + let target = root.0.join("Project"); + let other = root.0.join("project"); + let alias = root.0.join("alias"); + fs::create_dir(&target).unwrap(); + fs::create_dir(&other).unwrap(); + std::os::unix::fs::symlink(&target, &alias).unwrap(); + assert_eq!( + ProjectWindowRegistry::identity(&target).unwrap(), + ProjectWindowRegistry::identity(&alias).unwrap() + ); + assert_ne!( + ProjectWindowRegistry::identity(&target).unwrap(), + ProjectWindowRegistry::identity(&other).unwrap() + ); + } + + #[test] + fn rejects_missing_directories_and_files_without_claiming_them() { + let root = TestDirectory::new(); + assert!(ProjectWindowRegistry::identity(&root.0.join("missing")).is_err()); + let file = root.0.join("file"); + fs::write(&file, "content").unwrap(); + assert!(ProjectWindowRegistry::identity(&file).is_err()); + } +} diff --git a/windows/tauri/src-tauri/src/project_windows.rs b/windows/tauri/src-tauri/src/project_windows.rs new file mode 100644 index 000000000..cf6b167d3 --- /dev/null +++ b/windows/tauri/src-tauri/src/project_windows.rs @@ -0,0 +1,83 @@ +//! Tauri window ownership, activation, and lifecycle cleanup for local projects. + +use crate::project_window_registry::{ProjectWindowOwner, ProjectWindowRegistry}; +use std::path::Path; +use tauri::{AppHandle, Emitter, Manager, WebviewWindow}; +use tokio::sync::Mutex; + +#[derive(Default)] +pub struct ProjectWindows(pub Mutex); + +pub fn focus_project(app: &AppHandle, owner: &ProjectWindowOwner) -> Result<(), String> { + let window = app + .get_webview_window(&owner.label) + .ok_or("Project window is no longer available")?; + if let Some(id) = &owner.workspace_id { + window + .emit("activate_project_workspace", id) + .map_err(|error| error.to_string())?; + } + window.show().map_err(|error| error.to_string())?; + window.unminimize().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string()) +} + +#[tauri::command] +pub async fn claim_project_window( + app: AppHandle, + window: WebviewWindow, + path: String, + workspace_id: String, + activate: bool, +) -> Result { + let identity = + crate::project_window_registry::ProjectWindowRegistry::identity(Path::new(&path))?; + let state = app.state::(); + let mut registry = state.0.lock().await; + registry.retain_windows(|label| app.get_webview_window(label).is_some()); + let owner = registry.claim(identity, window.label(), Some(&workspace_id)); + // The owning frontend activates same-window tabs itself; emitting here would + // re-enter its initialization while the original open is still pending. + if activate && owner.label != window.label() { + focus_project(&app, &owner)?; + } + Ok(owner) +} + +#[tauri::command] +pub async fn release_project_window( + app: AppHandle, + window: WebviewWindow, + workspace_id: String, +) -> Result<(), String> { + app.state::() + .0 + .lock() + .await + .release(window.label(), Some(&workspace_id)); + Ok(()) +} + +#[tauri::command] +pub async fn release_pending_project_window( + app: AppHandle, + window: WebviewWindow, +) -> Result<(), String> { + app.state::() + .0 + .lock() + .await + .release_pending(window.label()); + Ok(()) +} + +pub fn release_window(app: &AppHandle, label: String) { + // Window destruction runs on the UI thread. Never block it while an async + // create command holds the registry and waits for the native window builder. + let app = app.clone(); + tauri::async_runtime::spawn(async move { + if let Some(state) = app.try_state::() { + state.0.lock().await.release(&label, None); + } + }); +} diff --git a/windows/tauri/src-tauri/src/run.rs b/windows/tauri/src-tauri/src/run.rs index 5ed1a5806..064107fac 100644 --- a/windows/tauri/src-tauri/src/run.rs +++ b/windows/tauri/src-tauri/src/run.rs @@ -22,6 +22,7 @@ use tauri::{AppHandle, Emitter, Manager}; mod launch_arguments; const CREATE_NO_WINDOW: u32 = 0x0800_0000; +const TOOLCHAIN_PROBE_TIMEOUT: Duration = Duration::from_secs(3); const RUN_OUTPUT_FLUSH_INTERVAL: Duration = Duration::from_millis(100); const RUN_OUTPUT_HIGH_WATER_BYTES: usize = 1_048_576; const RUN_OUTPUT_QUEUE_CAPACITY_CHUNKS: usize = 64; @@ -1291,7 +1292,12 @@ fn java_home_from_executable(executable: &Path) -> Option { .map(Path::to_path_buf) } -fn resolve_java_home(root: &Path, override_path: &str) -> Result, String> { +/// Resolves the JDK a Java or Maven launch would run with. Shared with the +/// Maven configuration surfaces so they report the value a launch actually uses. +pub(crate) fn resolve_java_home( + root: &Path, + override_path: &str, +) -> Result, String> { let configured = override_path.trim(); if !configured.is_empty() { let path = if Path::new(configured).is_absolute() { @@ -1306,8 +1312,8 @@ fn resolve_java_home(root: &Path, override_path: &str) -> Result, "JDK Home does not point to a directory: {configured}" )); } - Ok(discover_toolchains(Some(root)) - .java + // Resolving a JDK must not also run Maven wrappers or Node probes. + Ok(discover_java_runtimes(Some(root)) .first() .map(|runtime| runtime.home_path.clone())) } @@ -1495,7 +1501,10 @@ fn prepend_runtime_paths( Ok(()) } -fn resolve_maven_executable( +/// Resolves the Maven executable a launch would run, including the wrapper, +/// PATH, and `MAVEN_HOME` candidate chain. Shared with the Maven configuration +/// surfaces so they report the value a launch actually uses. +pub(crate) fn resolve_maven_executable( root: &Path, working_directory: &Path, override_path: &str, @@ -1532,15 +1541,9 @@ fn resolve_maven_executable( saw_incomplete_wrapper = true; } } - discover_toolchains(Some(root)) - .maven - .into_iter() - .find(|runtime| { - !runtime.executable_path.ends_with("mvnw.cmd") - && !runtime.executable_path.ends_with("mvnw.bat") - && !runtime.executable_path.ends_with("mvnw") - }) - .map(|runtime| runtime.executable_path) + // Match project import's filesystem-only fallback. Discovering a launcher + // must never execute a wrapper or download its Maven distribution. + maven_executable_without_probing(root, None) .ok_or_else(|| { if saw_incomplete_wrapper { "Maven wrapper is incomplete (.mvn/wrapper/maven-wrapper.properties is missing) and no system Maven was found. Install Maven or restore the wrapper files.".into() @@ -1616,19 +1619,26 @@ fn command_output(executable: &Path, arguments: &[&str]) -> String { .map(|argument| (*argument).to_string()) .collect::>(); let mut command = command_for_executable(&executable.to_string_lossy(), &arguments); - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); apply_creation_flags(&mut command); - command - .output() - .map(|output| { - let mut text = decode_process_bytes(&output.stdout); - text.push_str(&decode_process_bytes(&output.stderr)); - text - }) - .unwrap_or_default() + toolchain_probe_output(&mut command, Instant::now() + TOOLCHAIN_PROBE_TIMEOUT) +} + +fn toolchain_probe_output(command: &mut Command, deadline: Instant) -> String { + // Reuse the native runner's Job Object/process group, output bound and + // bounded drain so a timed-out probe cannot leave descendants behind. + let output = lithe_git_host::run( + command, + None, + || Instant::now() >= deadline, + || {}, + |_, _| {}, + ); + if output.failure.is_some() || !output.status.is_some_and(|status| status.success()) { + return String::new(); + } + let mut text = decode_process_bytes(&output.stdout); + text.push_str(&decode_process_bytes(&output.stderr)); + text } fn is_batch_file(executable: &str) -> bool { @@ -2563,6 +2573,24 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn toolchain_probe_discards_output_after_its_deadline() { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "echo expired probe"]); + // An already-expired deadline controls cancellation without a sleep or + // a deliberately hanging child. The runner owns and reaps the process. + assert!(toolchain_probe_output(&mut command, Instant::now()).is_empty()); + } + + #[test] + fn toolchain_probe_captures_both_version_streams() { + let mut command = Command::new("cmd.exe"); + command.args(["/D", "/C", "echo stdout-version & echo stderr-version 1>&2"]); + let output = toolchain_probe_output(&mut command, Instant::now() + TOOLCHAIN_PROBE_TIMEOUT); + assert!(output.contains("stdout-version")); + assert!(output.contains("stderr-version")); + } + #[test] fn maven_resolution_without_probing_prefers_a_usable_wrapper() { // The editor path awaits this resolution before starting JDT LS, so it diff --git a/windows/tauri/src-tauri/src/watcher.rs b/windows/tauri/src-tauri/src/watcher.rs index 763d7a050..950761625 100644 --- a/windows/tauri/src-tauri/src/watcher.rs +++ b/windows/tauri/src-tauri/src/watcher.rs @@ -1,6 +1,6 @@ -use lithe_project::FileWatcher; use lithe_project::git_watcher::{GitMetadataWatcher, GitWatchContext}; -use serde_json::{Value, json}; +use lithe_project::FileWatcher; +use serde_json::{json, Value}; use std::sync::Arc; use tauri::State; diff --git a/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts b/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts index 00bc32203..b48fbab77 100644 --- a/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts +++ b/windows/tauri/src/features/bootstrap/use-app-bootstrap.ts @@ -44,6 +44,12 @@ export function useAppBootstrap() { useSpringIndex(); useMybatisIndex(); + useEffect(() => { + void import("@/features/window/services/project-window-routing") + .then(({ projectWindowRouting }) => projectWindowRouting.initialize()) + .catch((error) => console.error("Failed to register project windows:", error)); + }, []); + useEffect(() => { let timer: number | null = null; const frame = window.requestAnimationFrame(() => { @@ -88,7 +94,14 @@ export function useAppBootstrap() { const request = parseWindowOpenUrl(new URL(window.location.href)); if (!request) return; - void enqueueWindowOpenRequest(request); + void enqueueWindowOpenRequest(request).catch((error) => { + console.error("Failed to open initial workspace:", error); + }).finally(() => { + // A rejected/invalid initial path must not leave an empty window owning it. + void invoke("release_pending_project_window").catch((error) => { + console.warn("Failed to release pending project window:", error); + }); + }); const nextUrl = `${window.location.pathname}${window.location.hash}`; window.history.replaceState(window.history.state, "", nextUrl || "/"); diff --git a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx index 5e71afe07..71b1ca545 100644 --- a/windows/tauri/src/features/command-palette/constants/settings-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/settings-actions.tsx @@ -74,6 +74,7 @@ const settingsTabLabels: Record = { logs: "Logs", advanced: "Advanced", terminal: "Terminal", + maven: "Maven", "file-explorer": "Files", }; diff --git a/windows/tauri/src/features/command-palette/constants/view-actions.tsx b/windows/tauri/src/features/command-palette/constants/view-actions.tsx index 3a58d384c..b3cb53439 100644 --- a/windows/tauri/src/features/command-palette/constants/view-actions.tsx +++ b/windows/tauri/src/features/command-palette/constants/view-actions.tsx @@ -160,6 +160,21 @@ export const createViewActions = (params: ViewActionsParams): Action[] => { onClose(); }, }, + { + id: "toggle-spring-endpoints", + label: + isBottomPaneVisible && bottomPaneActiveTab === "springEndpoints" + ? "View: Hide Spring Endpoints" + : "View: Show Spring Endpoints", + description: "Show or hide indexed Spring MVC endpoints", + icon: , + category: "View", + commandId: "workbench.toggleSpringEndpoints", + action: () => { + void keymapRegistry.executeCommand("workbench.toggleSpringEndpoints"); + onClose(); + }, + }, { id: "toggle-ai-chat-view", label: settings.isAIChatVisible ? "View: Hide AI Chat" : "View: Show AI Chat", diff --git a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts index 3ef7225c9..2bbe65c7b 100644 --- a/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts +++ b/windows/tauri/src/features/editor/engines/monaco/lsp-providers.ts @@ -5,6 +5,7 @@ import type * as Monaco from "monaco-editor"; import "monaco-editor/esm/vs/editor/contrib/gotoSymbol/browser/link/goToDefinitionAtPosition.js"; import type { CompletionItem, Hover } from "vscode-languageserver-protocol"; import { listen } from "@tauri-apps/api/event"; +import { ownsLspSession } from "@/platform/lsp-core-adapter"; import { isDocumentFeatureAvailable, LspClient, @@ -333,6 +334,9 @@ export function registerMonacoLspProviders() { } }); void listen("lsp://features-changed", () => semanticTokensChanged.fire()); + void listen<{ sessionId: string }>("lsp://semantic-tokens-refresh", ({ payload }) => { + if (ownsLspSession(payload.sessionId)) semanticTokensChanged.fire(); + }); languages.registerDocumentSemanticTokensProvider( selector, createMonacoSemanticTokenProvider({ diff --git a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts new file mode 100644 index 000000000..6fdea168e --- /dev/null +++ b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.test.ts @@ -0,0 +1,108 @@ +import { expect, mock, test } from "bun:test"; +import type * as Monaco from "monaco-editor"; +import fixture from "../../../../../../../shared/fixtures/lsp/semantic-tokens-v1.json"; +import { registerLspDocumentChangeFlusher } from "@/features/editor/lsp/pending-document-changes"; +import type { LspSemanticTokensResponse } from "@/features/editor/lsp/semantic-token-types"; +import { createMonacoSemanticTokenProvider } from "./semantic-token-provider"; + +function harness() { + const state = { + version: 1, + disposed: false, + open: true, + active: true, + eligible: true, + cancelled: false, + path: "C:/work/Main.java", + }; + const model = { + uri: { toString: () => "file:///C:/work/Main.java" }, + getVersionId: () => state.version, + isDisposed: () => state.disposed, + getLineCount: () => 3, + getLineMaxColumn: () => 30, + } as unknown as Monaco.editor.ITextModel; + const token = { + get isCancellationRequested() { + return state.cancelled; + }, + } as Monaco.CancellationToken; + const request = mock( + async (): Promise => fixture.expectedResult, + ); + const provider = createMonacoSemanticTokenProvider({ + client: { + getActiveServerEntryForFile: () => (state.active ? {} : null), + isDocumentOpen: () => state.open, + getSemanticTokens: request, + }, + filePathFromModel: () => state.path, + isLspModel: () => state.eligible, + }); + return { + state, + request, + provide: () => provider.provideDocumentSemanticTokens(model, null, token), + }; +} + +test("flushes pending text before requesting colors and maps the shared Core fixture to Monaco", async () => { + const h = harness(); + let flushed = false; + const unregister = registerLspDocumentChangeFlusher(h.state.path, async () => { + flushed = true; + }); + h.request.mockImplementation(async () => { + expect(flushed).toBe(true); + return fixture.expectedResult; + }); + try { + const result = (await h.provide()) as Monaco.languages.SemanticTokens; + // Server legend indices class=0/method=1 become Monaco class=2/method=13. + expect([...result.data]).toEqual([0, 6, 5, 2, 0, 2, 4, 3, 13, 0, 0, 7, 2, 13, 0]); + } finally { + unregister(); + } +}); + +for (const change of ["cancelled", "disposed", "version", "open", "path", "eligible"] as const) { + test(`discards a response after the model's ${change} changes`, async () => { + const h = harness(); + h.request.mockImplementation(async () => { + if (change === "version") h.state.version++; + else if (change === "path") h.state.path = "C:/work/Other.java"; + else if (change === "open" || change === "eligible") h.state[change] = false; + else h.state[change] = true; + return fixture.expectedResult; + }); + expect(await h.provide()).toBeNull(); + }); +} + +for (const unavailable of ["active", "open", "eligible", "cancelled"] as const) { + test(`does not request colors when ${unavailable} prevents highlighting`, async () => { + const h = harness(); + h.state[unavailable] = unavailable === "cancelled"; + expect(await h.provide()).toBeNull(); + expect(h.request).not.toHaveBeenCalled(); + }); +} + +test("does not request colors if the model changes during document synchronization", async () => { + const h = harness(); + const unregister = registerLspDocumentChangeFlusher(h.state.path, async () => { + h.state.version++; + }); + try { + expect(await h.provide()).toBeNull(); + expect(h.request).not.toHaveBeenCalled(); + } finally { + unregister(); + } +}); + +test("keeps basic highlighting available when Core returns no semantic colors", async () => { + const h = harness(); + h.request.mockResolvedValue(null); + expect(await h.provide()).toBeNull(); +}); diff --git a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts index f669f3ac0..f27d7ba1b 100644 --- a/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts +++ b/windows/tauri/src/features/editor/engines/monaco/semantic-token-provider.ts @@ -1,4 +1,5 @@ import type * as Monaco from "monaco-editor"; +import { flushLspDocumentChanges } from "@/features/editor/lsp/pending-document-changes"; import type { LspSemanticTokensResponse } from "@/features/editor/lsp/semantic-token-types"; import { encodeMonacoSemanticTokens, MONACO_SEMANTIC_TOKEN_LEGEND } from "./semantic-tokens"; @@ -35,11 +36,19 @@ export function createMonacoSemanticTokenProvider({ } const modelVersion = model.getVersionId(); + const isCurrent = () => + !cancellationToken.isCancellationRequested && + !model.isDisposed() && + model.getVersionId() === modelVersion && + isLspModel(model) && + filePathFromModel(model) === filePath && + client.isDocumentOpen(filePath); + // Monaco can request colors before the editor's document-change debounce fires. + await flushLspDocumentChanges(filePath); + if (!isCurrent()) return null; const response = await client.getSemanticTokens(filePath); if ( - cancellationToken.isCancellationRequested || - model.isDisposed() || - model.getVersionId() !== modelVersion || + !isCurrent() || !response || response.tokenTypes.length === 0 ) { diff --git a/windows/tauri/src/features/editor/lsp/lsp-client-semantic-tokens.test.ts b/windows/tauri/src/features/editor/lsp/lsp-client-semantic-tokens.test.ts new file mode 100644 index 000000000..e65281162 --- /dev/null +++ b/windows/tauri/src/features/editor/lsp/lsp-client-semantic-tokens.test.ts @@ -0,0 +1,48 @@ +import { expect, spyOn, test } from "bun:test"; +import * as adapter from "@/platform/lsp-core-adapter"; +import { LspClient, type LspDocumentAvailability } from "./lsp-client"; + +const ready = { + phase: "ready", + languageId: "java", + workspacePath: "C:/work", + feature: "supported", +} as const; + +test("semantic client invokes the Core adapter only for negotiated ready capabilities", async () => { + const client = Object.create(LspClient.prototype) as LspClient; + let availability: LspDocumentAvailability = ready; + client.getDocumentAvailability = (_target, feature) => { + expect(feature).toBe("semanticTokens"); + return availability; + }; + const response = { tokenTypes: ["class"], tokenModifiers: [], tokens: [] }; + const invoke = spyOn(adapter, "invokeLsp").mockResolvedValue(response); + try { + const unsupported: LspDocumentAvailability[] = [ + { phase: "unavailable", languageId: "java" }, + { + phase: "preparing", + languageId: "java", + workspacePath: "C:/work", + sessionPhase: "initializing", + }, + { ...ready, feature: "unknown" }, + { ...ready, feature: "unsupported" }, + ]; + for (const state of unsupported) { + availability = state; + expect(await client.getSemanticTokens("C:/work/Main.java")).toBeNull(); + } + expect(invoke).not.toHaveBeenCalled(); + availability = ready; + expect(await client.getSemanticTokens("C:/work/Main.java")).toEqual(response); + expect(invoke).toHaveBeenCalledWith("lsp_get_semantic_tokens", { + filePath: "C:/work/Main.java", + }); + invoke.mockRejectedValue(Object.assign(new Error("Document changed"), { code: "cancelled" })); + expect(await client.getSemanticTokens("C:/work/Main.java")).toBeNull(); + } finally { + invoke.mockRestore(); + } +}); diff --git a/windows/tauri/src/features/editor/lsp/lsp-client.ts b/windows/tauri/src/features/editor/lsp/lsp-client.ts index 7133a900b..58c88fba6 100644 --- a/windows/tauri/src/features/editor/lsp/lsp-client.ts +++ b/windows/tauri/src/features/editor/lsp/lsp-client.ts @@ -1474,6 +1474,8 @@ export class LspClient { async getSemanticTokens(filePath: string): Promise { if (!isLspSemanticCommandSupported("lsp_get_semantic_tokens")) return null; + const availability = this.getDocumentAvailability(filePath, "semanticTokens"); + if (availability.phase !== "ready" || availability.feature !== "supported") return null; try { return await invoke("lsp_get_semantic_tokens", { filePath }); } catch (error) { diff --git a/windows/tauri/src/features/file-system/services/file-watcher-listener.ts b/windows/tauri/src/features/file-system/services/file-watcher-listener.ts index 1276a70b1..e73f066d0 100644 --- a/windows/tauri/src/features/file-system/services/file-watcher-listener.ts +++ b/windows/tauri/src/features/file-system/services/file-watcher-listener.ts @@ -155,6 +155,11 @@ export async function initializeFileWatcherListener() { if (!rootFolderPath || !workspaceRoot) return; if (event_type === "rescan") { + window.dispatchEvent( + new CustomEvent("file-external-change", { + detail: { path: workspaceRoot, event_type }, + }), + ); const refreshRequest = getFileTreeRefreshRequest(event_type, workspaceRoot); if (refreshRequest) scheduleWorkspaceRefresh(workspaceId, workspaceRoot); return; diff --git a/windows/tauri/src/features/git/api/git-repo-api.test.ts b/windows/tauri/src/features/git/api/git-repo-api.test.ts index 30cba621c..11fe11762 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.test.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, mock, test } from "bun:test"; +import pathCases from "../../../../../../shared/fixtures/git/windows-paths.json"; + const invoke = mock(async (_command: string, _args?: unknown): Promise => null); const readDirectory = mock(async (_path: string): Promise => []); @@ -198,3 +200,49 @@ describe("resolveRepositoryForFile", () => { }); }); }); + + +describe("Windows Git path identity", () => { + for (const { input, normalized } of pathCases.accepted) { + test(`normalizes supported path ${JSON.stringify(input)}`, () => { + expect(normalizeRepositoryPath(input)).toBe(normalized); + }); + } + for (const input of pathCases.rejected) { + test(`rejects unsupported path ${JSON.stringify(input)} before discovery`, async () => { + expect(normalizeRepositoryPath(input)).toBe(input); + await expect(resolveRepositoryForFile("C:/work", input)).rejects.toThrow("Git does not support Windows paths"); + expect(invoke).not.toHaveBeenCalled(); + }); + } + for (const [input, root, directory] of [ + [String.raw`\\server\share\repo\src\main.ts`, "//server/share/repo", "//server/share/repo/src"], + [String.raw`\\?\C:\work\repo\src\main.ts`, "C:/work/repo", "C:/work/repo/src"], + [String.raw`\\?\UNC\server\share\repo\src\main.ts`, "//server/share/repo", "//server/share/repo/src"], + [String.raw`\\?\C:\src\main.ts`, "C:/", "C:/src"], + ["remote://host/repo/src/main.ts", "remote://host/repo", "remote://host/repo/src"], + ["wsl://Ubuntu/repo/src/main.ts", "wsl://Ubuntu/repo", "wsl://Ubuntu/repo/src"], + ]) { + test(`resolves an absolute file without prepending the active repository: ${input}`, async () => { + invoke.mockResolvedValue(root); + expect(await resolveRepositoryForFile("D:/different-repository", input!)).toEqual({ + repoPath: root, filePath: "src/main.ts", + }); + expect(invoke).toHaveBeenCalledWith("git_discover_repo", { path: directory }); + }); + } + test("does not hide an unsupported Core response with the deleted-directory fallback", async () => { + invoke.mockResolvedValueOnce(String.raw`\\?\C:\work\repo.`); + invoke.mockResolvedValue("C:/work/repo"); + await expect(resolveRepositoryForFile("C:/work/repo", "src/main.ts")).rejects.toThrow("Git does not support Windows paths"); + expect(invoke).toHaveBeenCalledTimes(1); + }); + test("rejects trailing dots in relative filenames before looking up their parent", async () => { + await expect(resolveRepositoryForFile("C:/work/repo", "src/main.ts.")).rejects.toThrow("Git does not support Windows paths"); + expect(invoke).not.toHaveBeenCalled(); + }); + test("retains POSIX names in remote and WSL repositories", () => { + expect(normalizeRepositoryPath("remote://host/repo./file ")).toBe("remote://host/repo./file "); + expect(normalizeRepositoryPath("wsl://Ubuntu/repo./file ")).toBe("wsl://Ubuntu/repo./file "); + }); +}); diff --git a/windows/tauri/src/features/git/api/git-repo-api.ts b/windows/tauri/src/features/git/api/git-repo-api.ts index dd1226de2..c20159657 100644 --- a/windows/tauri/src/features/git/api/git-repo-api.ts +++ b/windows/tauri/src/features/git/api/git-repo-api.ts @@ -1,5 +1,6 @@ import { invoke as tauriInvoke } from "@/platform/tauri-core"; -import { normalizePath as normalizeFilePath, stripTrailingPathSeparators } from "@/utils/path-helpers"; +import { normalizeGitOperationPath as normalizePath, UnsupportedGitPathError } from "./git-repository-path"; +export { normalizeRepositoryPath } from "./git-repository-path"; interface RepositoryDiscoveryCacheEntry { discoveredAt: number; @@ -27,22 +28,6 @@ const NOT_REPO_PATTERNS = [ const WORKSPACE_REPO_CACHE_TTL_MS = 5 * 60_000; const REPO_CACHE_TTL_MS = 5 * 60_000; const NEGATIVE_REPO_CACHE_TTL_MS = 5_000; -function normalizePath(path: string): string { - if (path.startsWith("wsl://") || path.startsWith("remote://")) { - const [scheme, rest] = path.split("://"); - const collapsedRest = (rest ?? "").replace(/\/{2,}/g, "/"); - const normalized = `${scheme}://${collapsedRest}`; - return normalized.length > `${scheme}://`.length + 1 - ? normalized.replace(/\/+$/, "") - : normalized; - } - - const unixPath = normalizeFilePath(path); - const collapsed = unixPath.replace(/\/{2,}/g, "/"); - // UNC repository identifiers must retain their network-root separator. - const normalized = unixPath.startsWith("//") ? `/${collapsed}` : collapsed; - return stripTrailingPathSeparators(normalized); -} function isAbsolutePath(path: string): boolean { return ( @@ -84,10 +69,6 @@ function toRelativePath(from: string, to: string): string { } -export function normalizeRepositoryPath(path: string): string { - return normalizePath(path); -} - export function isNotGitRepositoryError(error: unknown): boolean { const message = error instanceof Error @@ -172,11 +153,15 @@ export async function resolveRepositoryForFile( repoPath: string, filePath: string, ): Promise<{ repoPath: string; filePath: string } | null> { - const absoluteFilePath = isAbsolutePath(filePath) ? filePath : joinPath(repoPath, filePath); + const normalizedFilePath = normalizePath(filePath); + const absoluteFilePath = isAbsolutePath(normalizedFilePath) + ? normalizedFilePath + : joinPath(repoPath, normalizedFilePath); let discoveredRepo: string | null; try { discoveredRepo = await discoverRepo(parentPath(absoluteFilePath)); } catch (error) { + if (error instanceof UnsupportedGitPathError) throw error; const fallbackRepo = await discoverRepo(repoPath); const normalizedFallbackRepo = fallbackRepo ? normalizePath(fallbackRepo) : null; const normalizedAbsoluteFile = normalizePath(absoluteFilePath); diff --git a/windows/tauri/src/features/git/api/git-repository-path.ts b/windows/tauri/src/features/git/api/git-repository-path.ts new file mode 100644 index 000000000..a76073290 --- /dev/null +++ b/windows/tauri/src/features/git/api/git-repository-path.ts @@ -0,0 +1,52 @@ +import { normalizePath as normalizeFilePath, stripTrailingPathSeparators } from "../../../utils/path-helpers.ts"; + +export class UnsupportedGitPathError extends Error { + constructor() { + super("Git does not support Windows paths requiring verbatim semantics (trailing dots/spaces, reserved names or device namespaces)"); + this.name = "UnsupportedGitPathError"; + } +} + +export function normalizeRepositoryPath(path: string): string { + if (path.startsWith("wsl://") || path.startsWith("remote://")) { + const [scheme, rest] = path.split("://"); + const collapsedRest = (rest ?? "").replace(/\/{2,}/g, "/"); + const normalized = `${scheme}://${collapsedRest}`; + return normalized.length > `${scheme}://`.length + 1 + ? normalized.replace(/\/+$/, "") + : normalized; + } + + // UI comparison/rendering must remain total. Preserve unsupported input + // verbatim so the operation boundary can report it without aliasing a sibling. + if (isUnsupportedWindowsGitPath(path)) return path; + const unixPath = normalizeFilePath(path); + const collapsed = unixPath.replace(/\/{2,}/g, "/"); + // UNC repository identifiers must retain their network-root separator. + const normalized = unixPath.startsWith("//") ? `/${collapsed}` : collapsed; + return stripTrailingPathSeparators(normalized); +} + +function isUnsupportedWindowsGitPath(path: string): boolean { + if (path.startsWith("wsl://") || path.startsWith("remote://")) return false; + const nativePath = path.replace(/\\/g, "/"); + if (!/^(?:[A-Za-z]:|\/\/)/.test(nativePath)) return false; + const verbatim = nativePath.startsWith("//?/"); + if (nativePath.startsWith("//./") || (verbatim + && !/^\/\/\?\/(?:[A-Za-z]:\/|UNC\/[^/]+\/[^/]+)/i.test(nativePath))) return true; + const ordinaryPath = normalizeFilePath(path); + // UNC server/share names are routing identifiers, not local file components. + const components = ordinaryPath.startsWith("//") + ? ordinaryPath.slice(2).split("/").filter(Boolean).slice(2) + : ordinaryPath.split("/").slice(1); + return components.some((part) => { + if (part === "." || part === "..") return verbatim; + return /[. ]$/.test(part) + || /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/i.test(part); + }); +} + +export function normalizeGitOperationPath(path: string): string { + if (isUnsupportedWindowsGitPath(path)) throw new UnsupportedGitPathError(); + return normalizeRepositoryPath(path); +} diff --git a/windows/tauri/src/features/git/stores/git-repository.store.test.ts b/windows/tauri/src/features/git/stores/git-repository.store.test.ts index d7c3bd2f4..2569020c6 100644 --- a/windows/tauri/src/features/git/stores/git-repository.store.test.ts +++ b/windows/tauri/src/features/git/stores/git-repository.store.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, mock, spyOn, test } from "bun:test"; import * as repoApi from "../api/git-repo-api"; const discoverWorkspaceRepositories = mock(async (): Promise => ["C:/repo"]); +const realDiscoverWorkspaceRepositories = repoApi.discoverWorkspaceRepositories; const { createGitRepositoryStore } = await import("./git-repository.store"); let discoverySpy: ReturnType>; beforeEach(() => { @@ -10,6 +11,17 @@ beforeEach(() => { }); afterEach(() => discoverySpy.mockRestore()); +test("unsupported Windows roots report discovery errors without breaking repository state", async () => { + discoverySpy.mockImplementation(realDiscoverWorkspaceRepositories); + const store = createGitRepositoryStore(); + const unsupported = String.raw`\\?\C:\work\repo.`; + await store.getState().actions.syncWorkspaceRepositories(unsupported); + expect(store.getState().workspaceRootPath).toBe(unsupported); + expect(store.getState().error).toContain("Git does not support Windows paths"); + expect(store.getState().isDiscovering).toBe(false); + expect(store.getState().availableRepoPaths).toEqual([]); +}); + test("unchanged rescans do not notify repository-list consumers", async () => { const store = createGitRepositoryStore(); await store.getState().actions.syncWorkspaceRepositories("C:/repo"); diff --git a/windows/tauri/src/features/git/utils/git-graph-layout.test.ts b/windows/tauri/src/features/git/utils/git-graph-layout.test.ts index 254533069..2e0832769 100644 --- a/windows/tauri/src/features/git/utils/git-graph-layout.test.ts +++ b/windows/tauri/src/features/git/utils/git-graph-layout.test.ts @@ -25,7 +25,7 @@ const commit = ( const colorIndexOfCommit = (layout: GitGraphLayout, hash: string): number | null => { const row = layout.rows.find((candidate) => candidate.commit.hash === hash); if (!row) throw new Error(`No graph row for ${hash}`); - return row.incomingLaneColors[row.lane] ?? row.parentEdges[0]?.colorIndex ?? null; + return row.colorIndex; }; describe("Git graph layout", () => { @@ -45,9 +45,9 @@ describe("Git graph layout", () => { ]); expect(layout.rows[0].parentEdges).toHaveLength(2); - expect(layout.rows[0].parentEdges.map((edge) => edge.targetLane)).toEqual([0, 1]); - expect(layout.rows[1].parentEdges[0].targetLane).toBe(1); - expect(layout.rows[2].lane).toBe(1); + expect(new Set(layout.rows[0].parentEdges.map((edge) => edge.targetLane)).size).toBe(2); + expect(layout.rows[1].parentEdges[0].targetLane).not.toBeNull(); + expect(layout.laneCount).toBeGreaterThan(1); expect(layout.hasMissingParents).toBe(false); }); @@ -65,7 +65,7 @@ describe("Git graph layout", () => { const side = layout.rows[2]; expect(side.incomingLaneColors[side.lane]).toBeNull(); // Lanes of other branches passing through the tip row are still drawn. - expect(side.incomingLaneColors[layout.rows[1].lane]).not.toBeNull(); + expect(side.incomingLaneColors.some((color, lane) => lane !== side.lane && color !== null)).toBe(true); // The node keeps its own lane color even though its lane has no incoming segment. expect(side.colorIndex).not.toBe(layout.rows[1].colorIndex); }); @@ -86,7 +86,7 @@ describe("Git graph layout", () => { ]); }); - test("keeps a branch color stable when the topology moves it to another lane", () => { + test("keeps branch fragments distinct when topology moves them to another lane", () => { const featureTipInFirstLane = layoutGitGraph([ commit("tip", ["root"], "feature/orders"), commit("root"), @@ -100,22 +100,38 @@ describe("Git graph layout", () => { expect(colorIndexOfCommit(featureTipInFirstLane, "tip")).toBe( graphColorIndexForName("feature/orders"), ); - expect(colorIndexOfCommit(featureAsSecondParent, "tip")).toBe( - graphColorIndexForName("feature/orders"), - ); + // Once the feature is a secondary parent of the main head, IntelliJ's + // permanent layout gives that side fragment its layout color rather than + // treating it as a new graph head. It must still remain distinct from + // the main fragment. expect(featureAsSecondParent.rows[1].lane).toBe(1); - expect(colorIndexOfCommit(featureAsSecondParent, "merge")).toBe( - graphColorIndexForName("main"), + expect(colorIndexOfCommit(featureAsSecondParent, "tip")).not.toBe( + colorIndexOfCommit(featureAsSecondParent, "merge"), + ); + expect(colorIndexOfCommit(featureAsSecondParent, "tip")).not.toBe( + colorIndexOfCommit(featureAsSecondParent, "root"), ); }); + test("keeps a local head visually distinct from a decorated parent branch", () => { + const layout = layoutGitGraph([ + commit("local", ["base"], "HEAD -> feature/orders"), + commit("base", ["root"], "origin/main"), + commit("root"), + ]); + + expect(colorIndexOfCommit(layout, "local")).toBe(graphColorIndexForName("feature/orders")); + expect(colorIndexOfCommit(layout, "base")).toBe(graphColorIndexForName("origin/main")); + expect(colorIndexOfCommit(layout, "local")).not.toBe(colorIndexOfCommit(layout, "base")); + }); + test("gives unreferenced lanes a deterministic color", () => { const commits = [commit("a", ["b", "c"]), commit("b", ["d"]), commit("c", ["d"]), commit("d")]; const first = layoutGitGraph(commits); const second = layoutGitGraph(commits); expect(colorIndexOfCommit(first, "a")).toBe(0); - expect(colorIndexOfCommit(first, "c")).toBe(1); + expect(colorIndexOfCommit(first, "c")).toBe(2); expect(first.rows.map((row) => row.incomingLaneColors)).toEqual( second.rows.map((row) => row.incomingLaneColors), ); diff --git a/windows/tauri/src/features/git/utils/git-graph-layout.ts b/windows/tauri/src/features/git/utils/git-graph-layout.ts index 87eb7e708..92cfd35c9 100644 --- a/windows/tauri/src/features/git/utils/git-graph-layout.ts +++ b/windows/tauri/src/features/git/utils/git-graph-layout.ts @@ -34,16 +34,17 @@ export interface GitGraphLayout { hasMissingParents: boolean; } -interface Lane { - hash: string; - colorIndex: number; +interface GraphEdge { + up: number; + down: number | null; + parentHash: string; + id: string; } -function claimSlot(slots: Array): number { - const freeSlot = slots.findIndex((slot) => slot === null); - if (freeSlot >= 0) return freeSlot; - slots.push(null); - return slots.length - 1; +type GraphElement = { kind: "node"; row: number } | { kind: "edge"; edge: number }; + +function stableElementKey(element: GraphElement): number { + return element.kind === "node" ? -1 : element.edge; } export function parseGitDecorations(decorations: string): GitGraphLabel[] { @@ -54,7 +55,7 @@ export function parseGitDecorations(decorations: string): GitGraphLabel[] { if (raw.startsWith("HEAD -> ")) { return [ { title: "HEAD", kind: "head" }, - { title: raw.slice("HEAD -> ".length), kind: "branch" }, + { title: localBranchName(raw.slice("HEAD -> ".length)), kind: "branch" }, ]; } if (raw.startsWith("tag: ")) return [{ title: raw.slice("tag: ".length), kind: "tag" }]; @@ -64,15 +65,21 @@ export function parseGitDecorations(decorations: string): GitGraphLabel[] { if (raw.startsWith("origin/") || raw.startsWith("refs/remotes/")) { return [ { - title: raw.startsWith("refs/remotes/") ? raw.slice("refs/remotes/".length) : raw, + title: raw.startsWith("refs/remotes/") + ? raw.slice("refs/remotes/".length) + : raw, kind: "remote", }, ]; } - return [{ title: raw, kind: "branch" }]; + return [{ title: localBranchName(raw), kind: "branch" }]; }); } +function localBranchName(name: string): string { + return name.startsWith("refs/heads/") ? name.slice("refs/heads/".length) : name; +} + /** * IDEA's GraphColorGetterByHead picks a head's principal reference in this * order, so the same branch name always yields the same color id. @@ -87,118 +94,347 @@ function graphLabelPriority(label: GitGraphLabel): number { return label.kind === "tag" ? 4 : 6; } +/** The natural-name ordering used by IntelliJ's Git reference comparator. */ +function naturalCompare(left: string, right: string, ignoreCase = true): number { + const a = Array.from({ length: left.length }, (_, index) => left.charCodeAt(index)); + const b = Array.from({ length: right.length }, (_, index) => right.charCodeAt(index)); + let i = 0; + let j = 0; + const isDigit = (value: number) => value >= 48 && value <= 57; + const simpleCaseUnit = (value: number, uppercase: boolean) => { + if (value >= 0x61 && value <= 0x7a) return uppercase ? value - 0x20 : value; + if (value >= 0x41 && value <= 0x5a) return uppercase ? value : value + 0x20; + const mapped = String.fromCharCode(value)[uppercase ? "toUpperCase" : "toLowerCase"](); + return mapped.length === 1 ? mapped.charCodeAt(0) : value; + }; + + while (i < a.length && j < b.length) { + const first = a[i]; + const second = b[j]; + if ((isDigit(first) || first === 32) && (isDigit(second) || second === 32)) { + let firstStart = i; + while (firstStart < a.length && a[firstStart] === 32) firstStart += 1; + while (firstStart < a.length && a[firstStart] === 48) firstStart += 1; + let firstEnd = firstStart; + while (firstEnd < a.length && isDigit(a[firstEnd])) firstEnd += 1; + + let secondStart = j; + while (secondStart < b.length && b[secondStart] === 32) secondStart += 1; + while (secondStart < b.length && b[secondStart] === 48) secondStart += 1; + let secondEnd = secondStart; + while (secondEnd < b.length && isDigit(b[secondEnd])) secondEnd += 1; + + const firstLength = firstEnd - firstStart; + const secondLength = secondEnd - secondStart; + if (firstLength !== secondLength) return firstLength - secondLength; + for (let offset = 0; offset < firstLength; offset += 1) { + const difference = a[firstStart + offset] - b[secondStart + offset]; + if (difference !== 0) return difference; + } + const lengthDifference = firstEnd - i - (secondEnd - j); + if (lengthDifference !== 0) return lengthDifference; + for (let offset = 0; offset < firstStart - i; offset += 1) { + const difference = a[i + offset] - b[j + offset]; + if (difference !== 0) return difference; + } + i = firstEnd; + j = secondEnd; + continue; + } + + if (first === 32 && second > 32 && second < 48) return 1; + if (second === 32 && first > 32 && first < 48) return -1; + let difference = first - second; + if (difference !== 0 && ignoreCase) { + const upperDifference = simpleCaseUnit(first, true) - simpleCaseUnit(second, true); + difference = upperDifference !== 0 + ? upperDifference + : simpleCaseUnit(first, false) - simpleCaseUnit(second, false); + } + if (difference !== 0) return difference; + i += 1; + j += 1; + } + if (i < a.length) return 1; + if (j < b.length) return -1; + if (a.length !== b.length) return a.length - b.length; + return ignoreCase ? naturalCompare(left, right, false) : 0; +} + +function compareGraphLabels(left: GitGraphLabel, right: GitGraphLabel): number { + const priorityDifference = graphLabelPriority(left) - graphLabelPriority(right); + return priorityDifference !== 0 ? priorityDifference : naturalCompare(left.title, right.title); +} + export function bestGraphReferenceLabel(labels: GitGraphLabel[]): GitGraphLabel | null { let best: GitGraphLabel | null = null; for (const label of labels) { - if (best === null) { - best = label; - continue; + if (best === null || compareGraphLabels(label, best) < 0) best = label; + } + return best; +} + +function sortedHeads(labels: GitGraphLabel[][], children: number[][]): number[] { + return labels + .map((value, index) => ({ index, labels: value, best: bestGraphReferenceLabel(value) })) + .filter(({ index, labels: value }) => + children[index].length === 0 || value.some((label) => label.kind !== "tag"), + ) + .sort((left, right) => { + if (left.best && right.best) { + const comparison = compareGraphLabels(left.best, right.best); + return comparison !== 0 ? comparison : left.index - right.index; + } + if (left.best) return -1; + if (right.best) return 1; + return left.index - right.index; + }) + .map(({ index }) => index); +} + +/** + * Reproduces the permanent layout used by macOS's IntelliJ-compatible graph. + * The graph head order is intentionally independent from the screen columns: + * a parent branch reference can therefore keep its color when a local branch + * is added on top of it. + */ +function permanentLayout( + labels: GitGraphLabel[][], + parents: number[][], + children: number[][], +): { indices: number[]; colors: number[] } { + const indices = Array(parents.length).fill(0); + const colors = Array(parents.length).fill(0); + const nextParent = Array(parents.length).fill(0); + let layoutIndex = 1; + + for (const head of sortedHeads(labels, children)) { + if (indices[head] !== 0) continue; + const stack = [head]; + const headIndex = layoutIndex; + const headReference = bestGraphReferenceLabel(labels[head]); + const headColor = headReference ? graphColorIndexForName(headReference.title) : 0; + + while (stack.length > 0) { + const node = stack[stack.length - 1]; + const firstVisit = indices[node] === 0; + if (firstVisit) { + indices[node] = layoutIndex; + colors[node] = layoutIndex === headIndex ? headColor : layoutIndex; + } + while ( + nextParent[node] < parents[node].length && + indices[parents[node][nextParent[node]]] !== 0 + ) { + nextParent[node] += 1; + } + if (nextParent[node] < parents[node].length) { + stack.push(parents[node][nextParent[node]]); + } else { + if (firstVisit) layoutIndex += 1; + stack.pop(); + } } - const bestPriority = graphLabelPriority(best); - const priority = graphLabelPriority(label); - if (priority < bestPriority || (priority === bestPriority && label.title < best.title)) { - best = label; + } + + // A well-formed Git history always has a head for every component. Keep a + // deterministic fallback for incomplete snapshots or defensive fixtures. + for (let node = 0; node < parents.length; node += 1) { + if (indices[node] !== 0) continue; + const stack = [node]; + const headIndex = layoutIndex; + while (stack.length > 0) { + const current = stack[stack.length - 1]; + if (indices[current] === 0) { + indices[current] = layoutIndex; + colors[current] = layoutIndex === headIndex ? 0 : layoutIndex; + } + while ( + nextParent[current] < parents[current].length && + indices[parents[current][nextParent[current]]] !== 0 + ) { + nextParent[current] += 1; + } + if (nextParent[current] < parents[current].length) stack.push(parents[current][nextParent[current]]); + else { + layoutIndex += 1; + stack.pop(); + } } } - return best; + + return { indices, colors }; +} + +function compareEdgeToNode( + edge: GraphEdge, + node: number, + indices: number[], +): number { + const nodeIndex = indices[node]; + if (edge.down === null) return indices[edge.up] - nodeIndex; + const edgeIndex = Math.max(indices[edge.up], indices[edge.down]); + return edgeIndex !== nodeIndex ? edgeIndex - nodeIndex : edge.up - node; } +function compareElements( + left: GraphElement, + right: GraphElement, + edges: GraphEdge[], + indices: number[], +): number { + if (left.kind === "node" && right.kind === "node") return 0; + if (left.kind === "edge" && right.kind === "node") { + return compareEdgeToNode(edges[left.edge], right.row, indices); + } + if (left.kind === "node" && right.kind === "edge") { + return -compareEdgeToNode(edges[right.edge], left.row, indices); + } + if (left.kind === "node" || right.kind === "node") return 0; + + const first = edges[left.edge]; + const second = edges[right.edge]; + if (first.down === null) return -compareEdgeToNode(second, first.up, indices); + if (second.down === null) return compareEdgeToNode(first, second.up, indices); + if (first.up === second.up) { + return first.down < second.down + ? -compareEdgeToNode(second, first.down, indices) + : compareEdgeToNode(first, second.down, indices); + } + return first.up < second.up + ? compareEdgeToNode(first, second.up, indices) + : -compareEdgeToNode(second, first.up, indices); +} + +// Note: IntelliJ graph parity and cross-platform ownership are documented in +// .agents/notes/implemented/feature/2026-09-25-windows-git-graph-branch-colors.md. export function layoutGitGraph(commits: GitCommit[]): GitGraphLayout { if (commits.length === 0) return { rows: [], laneCount: 0, hasMissingParents: false }; - const knownHashes = new Set(commits.map((commit) => commit.hash)); - const slots: Array = []; - const rows: GitGraphRow[] = []; - let nextFallbackColorIndex = 0; - let maximumLaneCount = 0; - let hasMissingParents = false; - - const labelsByHash = new Map(); - const namedColorIndexByHash = new Map(); - for (const commit of commits) { - const labels = parseGitDecorations(commit.decorations); - labelsByHash.set(commit.hash, labels); - const reference = bestGraphReferenceLabel(labels); - if (reference) { - namedColorIndexByHash.set(commit.hash, graphColorIndexForName(reference.title)); - } + const firstRowByHash = new Map(); + for (const [row, commit] of commits.entries()) { + if (!firstRowByHash.has(commit.hash)) firstRowByHash.set(commit.hash, row); } - // A branch's color follows its name; a lane without a reference still needs a - // deterministic color so unreferenced fragments stay stable across renders. - const fallbackColorIndex = () => nextFallbackColorIndex++; - const colorIndexForHash = (hash: string) => - namedColorIndexByHash.get(hash) ?? fallbackColorIndex(); - - for (const commit of commits) { - let currentLane = slots.findIndex((slot) => slot?.hash === commit.hash); - // A branch tip has no child row above it, so its lane starts at the node. - const hasChildAbove = currentLane >= 0; - if (!hasChildAbove) { - currentLane = claimSlot(slots); - slots[currentLane] = { hash: commit.hash, colorIndex: colorIndexForHash(commit.hash) }; + + const labelsByRow = commits.map((commit) => parseGitDecorations(commit.decorations)); + const parents = commits.map(() => [] as number[]); + const children = commits.map(() => [] as number[]); + const edges: GraphEdge[] = []; + + for (const [row, commit] of commits.entries()) { + const seenParents = new Set(); + const seenMissingParents = new Set(); + for (const [parentIndex, parentHash] of commit.parentHashes.entries()) { + const parent = firstRowByHash.get(parentHash); + if (parent === undefined || parent <= row || !seenParents.add(parent)) { + if (parent === undefined && seenMissingParents.add(parentHash)) { + edges.push({ + up: row, + down: null, + parentHash, + id: `${row}:${parentHash}:missing`, + }); + } + continue; + } + parents[row].push(parent); + children[parent].push(row); + edges.push({ + up: row, + down: parent, + parentHash, + id: `${row}:${parent}:${parentHash}:${parentIndex}`, + }); } + } - const incomingLaneColors = slots.map((slot) => slot?.colorIndex ?? null); - const currentColorIndex = slots[currentLane]?.colorIndex ?? 0; - if (!hasChildAbove) incomingLaneColors[currentLane] = null; - slots[currentLane] = null; - - const parentEdges: GitGraphEdge[] = []; - commit.parentHashes.forEach((parentHash, parentIndex) => { - if (!knownHashes.has(parentHash)) { - hasMissingParents = true; - parentEdges.push({ - id: `${commit.hash}:${parentIndex}:${parentHash}`, - parentHash, - targetLane: null, - colorIndex: parentIndex === 0 ? currentColorIndex : fallbackColorIndex(), - isMissing: true, - }); - return; + const { indices, colors } = permanentLayout(labelsByRow, parents, children); + edges.sort((left, right) => { + if (left.up !== right.up) return left.up - right.up; + if (left.down !== right.down) return (left.down ?? Number.MAX_SAFE_INTEGER) - (right.down ?? Number.MAX_SAFE_INTEGER); + return left.parentHash < right.parentHash ? -1 : left.parentHash > right.parentHash ? 1 : 0; + }); + + const elements: GraphElement[][] = commits.map((_, row) => [{ kind: "node" as const, row }]); + const adjacent: number[][] = commits.map(() => []); + for (const [edgeIndex, edge] of edges.entries()) { + adjacent[edge.up].push(edgeIndex); + if (edge.down !== null) { + adjacent[edge.down].push(edgeIndex); + const span = edge.down - edge.up; + if (span > 1) { + if (span < 30) { + for (let row = edge.up + 1; row < edge.down; row += 1) { + elements[row].push({ kind: "edge", edge: edgeIndex }); + } + } else { + elements[edge.up + 1].push({ kind: "edge", edge: edgeIndex }); + elements[edge.down - 1].push({ kind: "edge", edge: edgeIndex }); + } } + } else if (edge.up + 1 < commits.length) { + elements[edge.up + 1].push({ kind: "edge", edge: edgeIndex }); + } + } - let targetLane = slots.findIndex((slot) => slot?.hash === parentHash); - let colorIndex: number; - if (targetLane >= 0) { - colorIndex = slots[targetLane]?.colorIndex ?? currentColorIndex; - } else if (parentIndex === 0) { - targetLane = currentLane; - colorIndex = currentColorIndex; - slots[targetLane] = { hash: parentHash, colorIndex }; + const positions = elements.map(() => new Map()); + const nodePositions = Array(commits.length).fill(0); + for (let row = 0; row < elements.length; row += 1) { + const rowElements = elements[row]; + rowElements.sort((left, right) => { + const order = compareElements(left, right, edges, indices); + return order === 0 ? stableElementKey(left) - stableElementKey(right) : order; + }); + elements[row] = rowElements; + rowElements.forEach((element, position) => { + if (element.kind === "node") { + nodePositions[row] = position; + for (const edge of adjacent[row]) positions[row].set(edge, position); } else { - targetLane = claimSlot(slots); - colorIndex = colorIndexForHash(parentHash); - slots[targetLane] = { hash: parentHash, colorIndex }; + positions[row].set(element.edge, position); } - - parentEdges.push({ - id: `${commit.hash}:${parentIndex}:${parentHash}`, - parentHash, - targetLane, - colorIndex, - isMissing: false, - }); }); + } + + const edgeColor = (edge: GraphEdge): number => { + if (edge.down === null) return colors[edge.up]; + return indices[edge.up] > indices[edge.down] ? colors[edge.up] : colors[edge.down]; + }; - while (slots.length > 0 && slots[slots.length - 1] === null) slots.pop(); - const edgeLaneCount = Math.max(-1, ...parentEdges.map((edge) => edge.targetLane ?? -1)) + 1; - const laneCount = Math.max( - incomingLaneColors.length, - slots.length, - currentLane + 1, - edgeLaneCount, + const rows: GitGraphRow[] = commits.map((commit, row) => { + const incomingLaneColors = elements[row].map((element) => + element.kind === "edge" + ? edgeColor(edges[element.edge]) + : adjacent[row].some((edge) => edges[edge].down === row) + ? colors[row] + : null, ); - maximumLaneCount = Math.max(maximumLaneCount, laneCount); - rows.push({ + const parentEdges = adjacent[row] + .filter((edgeIndex) => edges[edgeIndex].up === row) + .map((edgeIndex) => { + const edge = edges[edgeIndex]; + return { + id: edge.id, + parentHash: edge.parentHash, + targetLane: row + 1 < commits.length ? (positions[row + 1].get(edgeIndex) ?? null) : null, + colorIndex: edgeColor(edge), + isMissing: edge.down === null, + }; + }); + return { commit, - lane: currentLane, - colorIndex: currentColorIndex, - laneCount, + lane: nodePositions[row], + colorIndex: colors[row], + laneCount: elements[row].length, incomingLaneColors, parentEdges, - labels: labelsByHash.get(commit.hash) ?? [], - }); - } + labels: labelsByRow[row], + }; + }); - return { rows, laneCount: Math.max(1, maximumLaneCount), hasMissingParents }; + return { + rows, + laneCount: Math.max(1, ...rows.map((row) => row.laneCount)), + hasMissingParents: edges.some((edge) => edge.down === null), + }; } diff --git a/windows/tauri/src/features/keymaps/commands/command-registry.ts b/windows/tauri/src/features/keymaps/commands/command-registry.ts index 3f9c8ddfd..cab9393c8 100644 --- a/windows/tauri/src/features/keymaps/commands/command-registry.ts +++ b/windows/tauri/src/features/keymaps/commands/command-registry.ts @@ -114,6 +114,7 @@ import { toggleRunPane, toggleSidebar, toggleSourceControlSidebar, + toggleSpringEndpointsPane, toggleTerminalPane, toggleWordWrap, zoomIn, @@ -600,6 +601,13 @@ const viewCommands: Command[] = [ keybinding: "alt+9", execute: toggleGitLogPane, }, + { + id: "workbench.toggleSpringEndpoints", + title: "Toggle Spring Endpoints", + category: "View", + description: "Show or hide indexed Spring MVC endpoints", + execute: toggleSpringEndpointsPane, + }, { id: "workbench.toggleDiagnostics", title: "Show Diagnostics", diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.test.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.test.ts new file mode 100644 index 000000000..010143fbb --- /dev/null +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { registerCommands } from "./command-registry"; +import { toggleSpringEndpointsPane } from "./view-command-actions"; +import { keymapRegistry } from "../utils/registry"; + +afterEach(() => { + workspaceRuntimeRegistry.resetForTests(); +}); + +describe("toggleSpringEndpointsPane", () => { + test("opens and closes the Spring Endpoints bottom-pane tab", () => { + useUIState.setState({ + isBottomPaneVisible: false, + bottomPaneActiveTab: "terminal", + }); + + toggleSpringEndpointsPane(); + expect(useUIState.getState().isBottomPaneVisible).toBe(true); + expect(useUIState.getState().bottomPaneActiveTab).toBe("springEndpoints"); + + toggleSpringEndpointsPane(); + expect(useUIState.getState().isBottomPaneVisible).toBe(false); + expect(useUIState.getState().bottomPaneActiveTab).toBe("springEndpoints"); + }); +}); + +describe("Spring Endpoints command registration", () => { + test("registers the workbench command", () => { + registerCommands(); + expect(keymapRegistry.getCommand("workbench.toggleSpringEndpoints")).toMatchObject({ + title: "Toggle Spring Endpoints", + category: "View", + }); + }); +}); diff --git a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts index 5ac4a026e..43cd30791 100644 --- a/windows/tauri/src/features/keymaps/commands/view-command-actions.ts +++ b/windows/tauri/src/features/keymaps/commands/view-command-actions.ts @@ -64,6 +64,16 @@ export function toggleGitLogPane(): void { } } +export function toggleSpringEndpointsPane(): void { + const state = useUIState.getState(); + if (state.isBottomPaneVisible && state.bottomPaneActiveTab === "springEndpoints") { + state.setIsBottomPaneVisible(false); + } else { + state.setBottomPaneActiveTab("springEndpoints"); + state.setIsBottomPaneVisible(true); + } +} + export function openCommandPalette(): void { useUIState.getState().setIsCommandPaletteVisible(true); } diff --git a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx index 8e42b73be..496e0d8f7 100644 --- a/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx +++ b/windows/tauri/src/features/layout/components/bottom-pane/bottom-pane.tsx @@ -5,6 +5,7 @@ import DebuggerView from "@/features/debugger/components/debugger-view"; import DiagnosticsBuffer from "@/features/diagnostics/components/diagnostics-buffer"; import MavenRunPane from "@/features/maven/components/maven-run-pane"; import RunPane from "@/features/run/components/run-pane"; +import SpringEndpointsPane from "@/features/spring/components/spring-endpoints-pane"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useTranslation } from "@/i18n/locale-provider"; import { GitLogToolWindow } from "@/features/git/components/log/git-log-tool-window"; @@ -310,6 +311,12 @@ const BottomPane = () => { )} + + {bottomPaneActiveTab === "springEndpoints" && ( +
+ +
+ )} ); diff --git a/windows/tauri/src/features/layout/components/main-layout.tsx b/windows/tauri/src/features/layout/components/main-layout.tsx index 149c84039..18e071cdd 100644 --- a/windows/tauri/src/features/layout/components/main-layout.tsx +++ b/windows/tauri/src/features/layout/components/main-layout.tsx @@ -205,6 +205,8 @@ export function MainLayout() { console.warn("Persisted workspace no longer exists:", activeTab.path, error); } + const { projectWindowRouting } = await import("@/features/window/services/project-window-routing"); + await projectWindowRouting.release(activeTab.id); useWorkspaceTabsStore.getState().actions.removeProjectTab(activeTab.id); toast.warning( t("fileSystem.removedMissingProject", { name: getProjectDisplayLabel(activeTab) }), diff --git a/windows/tauri/src/features/maven/api/maven-host-api.ts b/windows/tauri/src/features/maven/api/maven-host-api.ts index 0404649e1..1c275b45e 100644 --- a/windows/tauri/src/features/maven/api/maven-host-api.ts +++ b/windows/tauri/src/features/maven/api/maven-host-api.ts @@ -2,8 +2,10 @@ import { invoke } from "@/platform/tauri-core"; import { inspectRunConfiguration } from "@/features/run/api/run-core-api"; import { resolveRunLaunch, startRunProcess, stopRunProcess } from "@/features/run/api/run-host-api"; import type { + MavenEffectiveConfiguration, MavenLaunchContext, MavenLaunchPlan, + MavenSettings, MavenStoredConfiguration, } from "../types/maven.types"; @@ -50,32 +52,65 @@ export function writeMavenConfiguration( }); } +/** + * Resolves what a Maven launch would actually use for the given settings, so the + * configuration surfaces can show what their empty fields fall back to. + */ +export async function resolveMavenEffectiveConfiguration( + root: string, + workingDirectory: string, + settings: MavenSettings, + dependencies = { + inspectRunConfiguration, + resolveConfiguration: (args: MavenSettings & { root: string; workingDirectory: string }) => + invoke("maven_resolve_effective_configuration", { args }), + }, +) { + return dependencies.resolveConfiguration({ + ...settings, + root, + workingDirectory, + javaHomePath: await resolveMavenJavaHome( + root, settings.javaHomePath, dependencies.inspectRunConfiguration, + ), + }); +} + +/** Both the settings hint and goal launch inherit the same saved project JDK. */ +async function resolveMavenJavaHome( + root: string, + configured: string | null | undefined, + inspect = inspectRunConfiguration, +) { + if (configured?.trim()) return configured.trim(); + try { + const defaults = (await inspect(root, false)).toolchain; + return defaults?.maven?.javaHomePath?.trim() || defaults?.java?.homePath?.trim() || ""; + } catch { + // Run documents are optional for Maven goals. Never log their contents or + // host paths, and retain the host's normal JDK selection on read failure. + console.warn( + "[maven] Project JDK defaults unavailable; using host JDK selection. Repair Run configuration documents in Settings.", + ); + return ""; + } +} + export async function resolveMavenLaunch( root: string, context: MavenLaunchContext, plan: MavenLaunchPlan, dependencies = { inspectRunConfiguration, resolveRunLaunch }, ) { - let defaults: Awaited>["toolchain"]; - if (!context.javaHomePath) { - try { - defaults = (await dependencies.inspectRunConfiguration(root, false)).toolchain; - } catch { - // Run documents are optional for Maven goals. Never log their contents or - // host paths, and retain the host's normal JDK selection on read failure. - console.warn( - "[maven] Project JDK defaults unavailable; using host JDK selection. Repair Run configuration documents in Settings.", - ); - } - } return dependencies.resolveRunLaunch({ root, executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: "", mavenExecutablePath: context.mavenExecutablePath ?? "", - mavenJavaHomePath: - context.javaHomePath || defaults?.maven?.javaHomePath || defaults?.java?.homePath || "", + mavenJavaHomePath: await resolveMavenJavaHome( + root, context.javaHomePath, dependencies.inspectRunConfiguration, + ), environment: {}, }); } diff --git a/windows/tauri/src/features/maven/api/maven-project-jdk.test.ts b/windows/tauri/src/features/maven/api/maven-project-jdk.test.ts index 960567182..5de878607 100644 --- a/windows/tauri/src/features/maven/api/maven-project-jdk.test.ts +++ b/windows/tauri/src/features/maven/api/maven-project-jdk.test.ts @@ -1,5 +1,5 @@ import { expect, mock, spyOn, test } from "bun:test"; -import { resolveMavenLaunch } from "./maven-host-api"; +import { resolveMavenEffectiveConfiguration, resolveMavenLaunch } from "./maven-host-api"; test("broken Run documents do not prevent independent Maven goals from using host defaults", async () => { const warning = spyOn(console, "warn").mockImplementation(() => {}); @@ -76,3 +76,51 @@ test("Maven inherits the saved project JDK without saving a discovered or inheri expect(overridden.environment.JAVA_HOME).toBe("C:/fixture/maven-jdk"); expect(dependencies.inspectRunConfiguration).not.toHaveBeenCalled(); }); + +test("Maven settings detection and goal launch inherit the same project JDK", async () => { + const settings = { + settingsPath: "D:/team/settings.xml", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }; + const defaults = { java: { homePath: "D:/team/jdk-21" }, maven: { javaHomePath: "" } }; + const dependencies = { + inspectRunConfiguration: mock(async () => ({ status: "missing", toolchain: defaults })), + resolveConfiguration: mock(async (args: typeof settings) => ({ + ...args, + detectedSettingsPath: null, + detectedLocalRepositoryPath: null, + detectedMavenExecutablePath: null, + detectedJavaHomePath: null, + })), + resolveRunLaunch: mock(async (args: { mavenJavaHomePath?: string }) => ({ + executable: "D:/team/maven/bin/mvn.cmd", + workingDirectory: "D:/team/project", + environment: { JAVA_HOME: args.mavenJavaHomePath ?? "" }, + })), + }; + const context = { version: 1 as const, reactorPath: ".", profiles: [], skipTests: false }; + const plan = { + version: 1 as const, + executable: { toolchain: "project-maven" as const }, + arguments: [], + workingDirectory: ".", + configurationFingerprint: "fixture", + }; + for (const mavenJdk of ["", "D:/team/maven-jdk"]) { + defaults.maven.javaHomePath = mavenJdk; + const hint = await resolveMavenEffectiveConfiguration("D:/team/project", ".", settings, dependencies); + const launch = await resolveMavenLaunch("D:/team/project", context, plan, dependencies); + expect(hint.javaHomePath).toBe(mavenJdk || defaults.java.homePath); + expect(hint.javaHomePath).toBe(launch.environment.JAVA_HOME); + expect(hint.settingsPath).toBe(settings.settingsPath); + expect(settings.javaHomePath).toBe(""); + } + dependencies.inspectRunConfiguration.mockClear(); + const explicit = await resolveMavenEffectiveConfiguration( + "D:/team/project", ".", { ...settings, javaHomePath: "D:/explicit-jdk" }, dependencies, + ); + expect(explicit.javaHomePath).toBe("D:/explicit-jdk"); + expect(dependencies.inspectRunConfiguration).not.toHaveBeenCalled(); +}); diff --git a/windows/tauri/src/features/maven/components/maven-detected-value.test.tsx b/windows/tauri/src/features/maven/components/maven-detected-value.test.tsx new file mode 100644 index 000000000..e31f9674c --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-detected-value.test.tsx @@ -0,0 +1,58 @@ +import { afterEach, expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import type { MavenEffectiveConfiguration } from "../types/maven.types"; +import { MavenDetectedValue } from "./maven-detected-value"; + +const workspaceId = "maven-detected-value-test"; +const effective: MavenEffectiveConfiguration = { + settingsPath: "D:/team/settings.xml", + localRepositoryPath: "D:/team/repository", + mavenExecutablePath: "D:/team/maven/bin/mvn.cmd", + javaHomePath: "D:/team/jdk-21", + detectedSettingsPath: "C:/fixture/.m2/settings.xml", + detectedLocalRepositoryPath: "C:/fixture/.m2/repository", + detectedMavenExecutablePath: "C:/fixture/maven/bin/mvn.cmd", + detectedJavaHomePath: "C:/fixture/jdk-17", +}; + +function renderHint(props: Parameters[0]) { + return renderToStaticMarkup( + + + + + , + ); +} + +afterEach(() => workspaceRuntimeRegistry.removeWorkspace(workspaceId)); + +test("an empty repository field shows the repository from the selected settings file", () => { + const html = renderHint({ field: "localRepositoryPath", value: "", effective, status: "ready" }); + expect(html).toContain("D:/team/repository"); + expect(html).not.toContain("C:/fixture/.m2/repository"); +}); + +test("inherited JDK and Maven hints reflect the effective selection", () => { + for (const field of ["javaHomePath", "mavenExecutablePath", "settingsPath"] as const) { + const html = renderHint({ field, value: "", effective, status: "ready" }); + expect(html).toContain(effective[field]!); + expect(html).not.toContain("C:/fixture/"); + } +}); + +test("an explicit path does not display an inactive automatic fallback", () => { + expect(renderHint({ field: "settingsPath", value: "D:/custom.xml", effective, status: "ready" })) + .toBe(""); +}); + +test("a previous result is hidden while the new configuration is loading or failed", () => { + for (const status of ["loading", "failed"] as const) { + const html = renderHint({ field: "localRepositoryPath", value: "", effective, status }); + expect(html).toContain('data-maven-detected="localRepositoryPath"'); + expect(html).not.toContain("D:/team/repository"); + } +}); diff --git a/windows/tauri/src/features/maven/components/maven-detected-value.tsx b/windows/tauri/src/features/maven/components/maven-detected-value.tsx new file mode 100644 index 000000000..1f7370189 --- /dev/null +++ b/windows/tauri/src/features/maven/components/maven-detected-value.tsx @@ -0,0 +1,90 @@ +import { useRunStore } from "@/features/run/stores/run.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import type { + MavenEffectiveConfiguration, + MavenEffectiveConfigurationStatus, + MavenSettings, +} from "../types/maven.types"; + +function canonicalPath(value: string): string { + return value.trim().replace(/[\\/]+$/, "").replace(/\\/g, "/").toLowerCase(); +} + +function versionForPath(path: string, candidates: Array<{ path: string; version: string }>): string { + const target = canonicalPath(path); + return ( + candidates.find((candidate) => candidate.path && canonicalPath(candidate.path) === target) + ?.version.trim() ?? "" + ); +} + +/** + * The line under a Maven path field showing what automatic detection found on + * this machine with the saved project settings applied. An explicit field + * already shows the user's selection; its automatic fallback is not active. + */ +export function MavenDetectedValue({ + field, + value, + effective, + status = "idle", +}: { + field: keyof MavenSettings; + value: string; + effective: MavenEffectiveConfiguration | null; + status?: MavenEffectiveConfigurationStatus; +}) { + const { t } = useTranslation(); + const discoveredMaven = useRunStore((state) => state.discoveredMaven); + const discoveredJava = useRunStore((state) => state.discoveredJava); + if (value.trim()) return null; + if (!effective || status === "loading" || status === "failed") { + if (status === "loading") { + return ( +

+ {t("maven.detecting")} +

+ ); + } + if (status === "failed") { + return ( +

+ {t("maven.detectedMissing")} +

+ ); + } + return null; + } + + const detected = effective[field]; + const version = + !detected + ? "" + : field === "mavenExecutablePath" + ? versionForPath( + detected, + discoveredMaven.map((runtime) => ({ + path: runtime.executablePath, + version: runtime.version, + })), + ) + : field === "javaHomePath" + ? versionForPath( + detected, + discoveredJava.map((runtime) => ({ + path: runtime.homePath, + version: runtime.version, + })), + ) + : ""; + const label = detected && version ? `${detected} (${version})` : detected; + return ( +

+ {label ? t("maven.detectedValue", { value: label }) : t("maven.detectedMissing")} +

+ ); +} diff --git a/windows/tauri/src/features/maven/services/maven-module-debug.test.ts b/windows/tauri/src/features/maven/services/maven-module-debug.test.ts index 760f53e32..4ff2cf1b0 100644 --- a/windows/tauri/src/features/maven/services/maven-module-debug.test.ts +++ b/windows/tauri/src/features/maven/services/maven-module-debug.test.ts @@ -234,6 +234,7 @@ for (const execution of ["service", "application"] as const) { processes.set(sessionId, executionId!); }, stopRunProcess, + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: async () => null, }; const selected = { ...configuration, execution }; diff --git a/windows/tauri/src/features/maven/stores/maven.store.test.ts b/windows/tauri/src/features/maven/stores/maven.store.test.ts index 5c3453722..594699127 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.test.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.test.ts @@ -86,7 +86,9 @@ const dependencyTree: MavenDependenciesResponse = { ], }; -const scanMavenProject = mock(async (_root: string, _paths?: string[]) => project); +const scanMavenProject = mock( + async (_root: string, _paths?: string[]): Promise => project, +); const createMavenLaunchPlan = mock(async () => launchPlan); const dependencyOutputFile = (sessionId: string) => `C:/Users/dev/AppData/Local/lithe/cache/maven-dependency-trees/${sessionId.replace(":", "_")}.txt`; @@ -137,6 +139,17 @@ const resolveMavenLaunch = mock(async () => ({ environment: {}, })); const saveWorkspaceBeforeLaunch = mock(async (_workspaceId: string): Promise => undefined); +const effectiveConfiguration = { + settingsPath: "C:/Users/example/.m2/settings.xml", + localRepositoryPath: "C:/Users/example/.m2/repository", + mavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + javaHomePath: "C:/Java/jdk-21", + detectedSettingsPath: "C:/Users/example/.m2/settings.xml", + detectedLocalRepositoryPath: "C:/Users/example/.m2/repository", + detectedMavenExecutablePath: "D:/Tools/apache-maven/bin/mvn.cmd", + detectedJavaHomePath: "C:/Java/jdk-21", +}; +const resolveMavenEffectiveConfiguration = mock(async () => effectiveConfiguration); const startMavenProcess = mock(async () => undefined); const stopMavenProcess = mock(async () => undefined); const trace = mock(() => undefined); @@ -165,6 +178,7 @@ const dependencies = { resolveEffectiveMavenExecutable, resolveMavenLaunch, resolveJavaTestClass, + resolveMavenEffectiveConfiguration, saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, @@ -206,6 +220,8 @@ beforeEach(() => { removeMavenDependencyOutput.mockClear(); readMavenDependencies.mockClear(); resolveMavenLaunch.mockClear(); + resolveMavenEffectiveConfiguration.mockReset(); + resolveMavenEffectiveConfiguration.mockResolvedValue(effectiveConfiguration); saveWorkspaceBeforeLaunch.mockReset(); saveWorkspaceBeforeLaunch.mockResolvedValue(undefined); startMavenProcess.mockClear(); @@ -500,6 +516,163 @@ describe("Maven workspace state", () => { }); }); + test("skips seeding when no Maven project is loaded", () => { + const store = createMavenStore("workspace", dependencies); + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + }); + expect(store.getState().mavenExecutablePath).toBe(""); + }); + + test("imports a legacy toolchain only before Maven settings exist", async () => { + const store = createMavenStore("workspace", dependencies); + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + expect(store.getState().mavenExecutablePath).toBe(""); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + expect(store.getState().javaHomePath).toBe("C:/Java/jdk-21"); + expect(store.getState().reloadRequired).toBe(false); + + store.getState().actions.seedLocalConfiguration({ + settingsPath: "C:/Other/settings.xml", + mavenExecutablePath: "D:/Other/maven", + }); + expect(store.getState().settingsPath).toBe(""); + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + }); + + test("imports a legacy toolchain after a project loads with no saved settings", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + expect(store.getState().mavenExecutablePath).toBe(""); + + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + }); + + expect(store.getState().mavenExecutablePath).toBe("D:/Tools/apache-maven"); + expect(store.getState().reloadRequired).toBe(false); + }); + + test("keeps automatic fields empty after Maven settings are saved", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + store.getState().actions.updateLocalConfiguration({ + settingsPath: "C:/custom/settings.xml", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }); + store.getState().actions.acknowledgeReload(); + expect(store.getState().reloadRequired).toBe(false); + + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + javaHomePath: "C:/Java/jdk-21", + }); + + expect(store.getState().settingsPath).toBe("C:/custom/settings.xml"); + expect(store.getState().mavenExecutablePath).toBe(""); + expect(store.getState().javaHomePath).toBe(""); + }); + + test("does not import a legacy toolchain over a saved automatic configuration", async () => { + loadMavenConfiguration.mockResolvedValue({ + local: { + version: 1, + settingsPath: null, + localRepositoryPath: null, + mavenExecutablePath: null, + javaHomePath: null, + }, + }); + const store = createMavenStore("workspace", dependencies); + store.getState().actions.seedLocalConfiguration({ + mavenExecutablePath: "D:/Tools/apache-maven", + }); + + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(store.getState().mavenExecutablePath).toBe(""); + }); + + test("shares the effective machine configuration with every surface", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + // Loading the project already refreshes the detected values. + expect(store.getState().effectiveConfiguration).toEqual(effectiveConfiguration); + expect(resolveMavenEffectiveConfiguration).toHaveBeenCalledWith( + "D:/work", + "reactor", + expect.objectContaining({ mavenExecutablePath: "", javaHomePath: "" }), + ); + + // A detection failure hides the detected values instead of reporting one. + resolveMavenEffectiveConfiguration.mockRejectedValueOnce(new Error("detection failed")); + await store.getState().actions.resolveEffectiveConfiguration(); + expect(store.getState().effectiveConfiguration).toBeNull(); + }); + + test("clears the effective configuration for a workspace without Maven", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + expect(store.getState().effectiveConfiguration).toEqual(effectiveConfiguration); + resolveMavenEffectiveConfiguration.mockClear(); + + // A workspace without a detected Maven project never shows detected values, + // and never asks the host to detect them either. + scanMavenProject.mockResolvedValue(null); + await store.getState().actions.loadProject("D:/plain", []); + await store.getState().actions.resolveEffectiveConfiguration(); + + expect(store.getState().effectiveConfiguration).toBeNull(); + expect(resolveMavenEffectiveConfiguration).not.toHaveBeenCalled(); + }); + + test("settings hints use the inherited Maven installation carried by the launch context", async () => { + resolveEffectiveMavenExecutable.mockResolvedValueOnce("D:/team/maven/bin/mvn.cmd"); + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + + expect(store.getState().mavenExecutablePath).toBe(""); + expect(resolveMavenEffectiveConfiguration).toHaveBeenLastCalledWith( + "D:/work", "reactor", + expect.objectContaining({ mavenExecutablePath: "D:/team/maven/bin/mvn.cmd" }), + ); + expect(mavenLaunchContext(store.getState())?.mavenExecutablePath) + .toBe("D:/team/maven/bin/mvn.cmd"); + }); + + test("ignores Maven detection that returns after switching to a non-Maven project", async () => { + const store = createMavenStore("workspace", dependencies); + await store.getState().actions.loadProject("D:/work", ["reactor/pom.xml"]); + const releaseDetection = deferred(); + resolveMavenEffectiveConfiguration.mockImplementationOnce(() => releaseDetection.promise); + + const pendingDetection = store.getState().actions.resolveEffectiveConfiguration(); + try { + expect(resolveMavenEffectiveConfiguration).toHaveBeenCalledTimes(2); + scanMavenProject.mockResolvedValue(null); + await store.getState().actions.loadProject("D:/plain", []); + await store.getState().actions.resolveEffectiveConfiguration(); + expect(store.getState().effectiveConfigurationStatus).toBe("idle"); + + releaseDetection.resolve(effectiveConfiguration); + await pendingDetection; + expect(store.getState().effectiveConfiguration).toBeNull(); + expect(store.getState().effectiveConfigurationStatus).toBe("idle"); + } finally { + releaseDetection.resolve(effectiveConfiguration); + await pendingDetection; + } + }); + test("serializes rapid configuration writes so the newest value wins", async () => { const firstStarted = deferred(); const firstWrite = deferred(); diff --git a/windows/tauri/src/features/maven/stores/maven.store.ts b/windows/tauri/src/features/maven/stores/maven.store.ts index 3c0f5c7c1..054e4a776 100644 --- a/windows/tauri/src/features/maven/stores/maven.store.ts +++ b/windows/tauri/src/features/maven/stores/maven.store.ts @@ -15,6 +15,7 @@ import { createMavenDependencyOutput, loadMavenConfiguration, removeMavenDependencyOutput, + resolveMavenEffectiveConfiguration, resolveMavenLaunch, startMavenProcess, stopMavenProcess, @@ -30,10 +31,12 @@ import { resolveEffectiveMavenExecutable } from "../services/resolve-maven-toolc import type { MavenDependencyLoad, MavenDiagnostic, + MavenEffectiveConfiguration, MavenLaunchContext, MavenLocalConfiguration, MavenPortableConfiguration, MavenProfile, + MavenEffectiveConfigurationStatus, MavenProject, MavenProjectStatus, MavenSettings, @@ -75,6 +78,7 @@ export interface MavenStoreDependencies { resolveEffectiveMavenExecutable: typeof resolveEffectiveMavenExecutable; resolveMavenLaunch: typeof resolveMavenLaunch; resolveJavaTestClass: typeof resolveJavaTestClass; + resolveMavenEffectiveConfiguration: typeof resolveMavenEffectiveConfiguration; saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; scanMavenProject: typeof scanMavenProject; startMavenProcess: typeof startMavenProcess; @@ -96,6 +100,7 @@ const defaultMavenStoreDependencies: MavenStoreDependencies = { resolveEffectiveMavenExecutable, resolveMavenLaunch, resolveJavaTestClass, + resolveMavenEffectiveConfiguration, saveWorkspaceBeforeLaunch, scanMavenProject, startMavenProcess, @@ -140,6 +145,12 @@ export interface MavenState { resolvedMavenExecutablePath: string; javaHomePath: string; configurationSaveError: string | null; + /** + * What a Maven launch would use for the saved configuration, shared by every + * surface that shows these fields. `null` hides the detected values. + */ + effectiveConfiguration: MavenEffectiveConfiguration | null; + effectiveConfigurationStatus: MavenEffectiveConfigurationStatus; reloadRequired: boolean; projectReloadRequired: boolean; reloadRevision: number; @@ -179,6 +190,8 @@ export interface MavenState { setSkipTests: (enabled: boolean) => void; updateLocalConfiguration: (settings: MavenSettings) => void; saveLocalConfiguration: (settings: MavenSettings) => Promise; + seedLocalConfiguration: (settings: Partial) => void; + resolveEffectiveConfiguration: () => Promise; acknowledgeReload: (revision?: number) => void; runGoals: ( goals: string[], @@ -238,6 +251,42 @@ function normalizedPath(value: string | null | undefined): string { return value?.trim() ?? ""; } +type MavenLocalPaths = Pick< + MavenState, + "settingsPath" | "localRepositoryPath" | "mavenExecutablePath" | "javaHomePath" +>; + +function mavenLocalPaths(settings: Partial): MavenLocalPaths { + return { + settingsPath: normalizedPath(settings.settingsPath), + localRepositoryPath: normalizedPath(settings.localRepositoryPath), + mavenExecutablePath: normalizedPath(settings.mavenExecutablePath), + javaHomePath: normalizedPath(settings.javaHomePath), + }; +} + +function withBlankFieldsFilled( + current: MavenLocalPaths, + incoming: Partial, +): MavenLocalPaths { + const next = mavenLocalPaths(incoming); + return { + settingsPath: current.settingsPath || next.settingsPath, + localRepositoryPath: current.localRepositoryPath || next.localRepositoryPath, + mavenExecutablePath: current.mavenExecutablePath || next.mavenExecutablePath, + javaHomePath: current.javaHomePath || next.javaHomePath, + }; +} + +function sameLocalPaths(left: MavenLocalPaths, right: MavenLocalPaths): boolean { + return ( + left.settingsPath === right.settingsPath && + left.localRepositoryPath === right.localRepositoryPath && + left.mavenExecutablePath === right.mavenExecutablePath && + left.javaHomePath === right.javaHomePath + ); +} + export function availableMavenProfiles(state: Pick) { const profiles = new Map(); for (const profile of state.project?.profiles ?? []) profiles.set(profile.id, profile); @@ -366,6 +415,12 @@ export const createMavenStore = ( ) => { let projectLoadRevision = 0; let configurationRevision = 0; + let effectiveConfigurationRevision = 0; + // True once this workspace has a Maven local document, including one the user + // saved with blank fields. Blank then means automatic, so a legacy run + // toolchain must not fill those fields back in. + let localConfigurationPersisted = false; + let pendingLocalSeed: MavenLocalPaths | null = null; let launchRevision = 0; let diagnosticsRevision = 0; let dependencyRevision = 0; @@ -377,6 +432,34 @@ export const createMavenStore = ( let watchedPomPaths = new Set(); const now = dependencyScheduler.now ?? (() => Date.now()); + const rememberPendingSeed = (settings: Partial) => { + pendingLocalSeed = withBlankFieldsFilled( + pendingLocalSeed ?? { + settingsPath: "", + localRepositoryPath: "", + mavenExecutablePath: "", + javaHomePath: "", + }, + settings, + ); + }; + + // Applies a toolchain seed only while this workspace has never saved Maven + // settings. A saved document, even one whose paths are blank, already chose + // automatic for those fields. + const adoptPendingLocalSeed = (settings: MavenLocalPaths) => { + if (localConfigurationPersisted) { + pendingLocalSeed = null; + return { settings, migrated: false }; + } + if (!pendingLocalSeed) return { settings, migrated: false }; + const next = withBlankFieldsFilled(settings, pendingLocalSeed); + pendingLocalSeed = null; + const migrated = !sameLocalPaths(settings, next); + if (migrated) localConfigurationPersisted = true; + return { settings: next, migrated }; + }; + return createStore()((set, get) => { const pomWatchOperations = dependencies.createMavenPomWatchOperations(workspaceId); const synchronizePomWatches = (desiredPaths: ReadonlySet) => { @@ -585,6 +668,8 @@ export const createMavenStore = ( resolvedMavenExecutablePath: "", javaHomePath: "", configurationSaveError: null, + effectiveConfiguration: null, + effectiveConfigurationStatus: "idle", reloadRequired: false, projectReloadRequired: false, reloadRevision: 0, @@ -619,6 +704,8 @@ export const createMavenStore = ( releaseMavenSessionWorkspace(previous.activeSessionId); } if (previous.root && previous.root !== root) { + pendingLocalSeed = null; + localConfigurationPersisted = false; await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision) return; } @@ -657,6 +744,7 @@ export const createMavenStore = ( if (!project) { await synchronizePomWatches(new Set()); if (projectLoadRevision !== revision || get().root !== root) return; + localConfigurationPersisted = false; set({ projectStatus: "ready", project: null, @@ -721,12 +809,26 @@ export const createMavenStore = ( const selectedProfiles = normalizedProfiles( stored.portable?.selectedProfiles ?? defaultProfiles, ).filter((profile) => knownProfiles.has(profile)); - const mavenExecutablePath = normalizedPath(stored.local?.mavenExecutablePath); + if (!preserveLatestInMemoryConfiguration) { + localConfigurationPersisted = stored.local != null; + } + const loadedPaths = { + settingsPath: normalizedPath(stored.local?.settingsPath), + localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), + mavenExecutablePath: normalizedPath(stored.local?.mavenExecutablePath), + javaHomePath: normalizedPath(stored.local?.javaHomePath), + }; + const adopted = preserveLatestInMemoryConfiguration + ? { settings: loadedPaths, migrated: false } + : adoptPendingLocalSeed(loadedPaths); // Project import must follow the same installation the command line // uses, so JDT LS reads its repository and mirrors instead of the - // embedded defaults. + // embedded defaults. The adopted path is the one launch will use. const resolvedMavenExecutablePath = - await dependencies.resolveEffectiveMavenExecutable(root, mavenExecutablePath); + await dependencies.resolveEffectiveMavenExecutable( + root, + adopted.settings.mavenExecutablePath, + ); if (projectLoadRevision !== revision || get().root !== root) return; set({ projectStatus: "ready", @@ -735,12 +837,10 @@ export const createMavenStore = ( selectedProfiles, customProfiles, skipTests: stored.portable?.skipTests ?? false, - settingsPath: normalizedPath(stored.local?.settingsPath), - localRepositoryPath: normalizedPath(stored.local?.localRepositoryPath), - mavenExecutablePath, + ...adopted.settings, resolvedMavenExecutablePath, - javaHomePath: normalizedPath(stored.local?.javaHomePath), }); + if (adopted.migrated) persistConfiguration(); } catch (error) { if (projectLoadRevision !== revision || get().root !== root) return; const message = @@ -755,6 +855,7 @@ export const createMavenStore = ( })); return; } + localConfigurationPersisted = false; set({ projectStatus: "failed", projectError: message, @@ -772,6 +873,12 @@ export const createMavenStore = ( lastTestRun: null, testOutcomes: [], }); + } finally { + // Deliberately not awaited: the scan has already produced the project + // state, and detection probes the machine, which must not delay + // opening a workspace. The action resolves once detection settles. + const resolveEffectiveConfiguration = get().actions.resolveEffectiveConfiguration; + void resolveEffectiveConfiguration(); } }, @@ -867,7 +974,67 @@ export const createMavenStore = ( ? {} : { resolvedMavenExecutablePath: "" }), }); + localConfigurationPersisted = true; + pendingLocalSeed = null; configurationDidChange(); + void get().actions.resolveEffectiveConfiguration(); + }, + + // Imports a legacy run-toolchain path into a blank field, once, before + // this workspace has its own Maven settings document. A document that + // exists — including one the user saved with blank paths — already owns + // those fields, so a later toolchain value cannot overwrite automatic. + // The reload prompt stays off: this migrates an already effective value. + seedLocalConfiguration: (settings) => { + if (localConfigurationPersisted) return; + const state = get(); + if (!state.project) { + rememberPendingSeed(settings); + return; + } + const next = withBlankFieldsFilled(state, settings); + if (sameLocalPaths(state, next)) return; + localConfigurationPersisted = true; + pendingLocalSeed = null; + set(next); + persistConfiguration(); + void get().actions.resolveEffectiveConfiguration(); + }, + + // Resolves the launch values and the machine-detected values for the + // saved configuration. A detection failure clears the result so the + // surfaces can say detection did not finish, and a workspace without a + // Maven project clears it without asking the host at all. + resolveEffectiveConfiguration: async () => { + const state = get(); + const revision = ++effectiveConfigurationRevision; + if (!state.root || !state.project) { + if ( + get().effectiveConfiguration !== null || + get().effectiveConfigurationStatus !== "idle" + ) { + set({ effectiveConfiguration: null, effectiveConfigurationStatus: "idle" }); + } + return; + } + set({ effectiveConfigurationStatus: "loading" }); + try { + const resolved = await dependencies.resolveMavenEffectiveConfiguration( + state.root, + state.project.relativePath, + { + settingsPath: state.settingsPath, + localRepositoryPath: state.localRepositoryPath, + mavenExecutablePath: state.mavenExecutablePath || state.resolvedMavenExecutablePath, + javaHomePath: state.javaHomePath, + }, + ); + if (effectiveConfigurationRevision !== revision) return; + set({ effectiveConfiguration: resolved, effectiveConfigurationStatus: "ready" }); + } catch { + if (effectiveConfigurationRevision !== revision) return; + set({ effectiveConfiguration: null, effectiveConfigurationStatus: "failed" }); + } }, saveLocalConfiguration: async (settings) => { diff --git a/windows/tauri/src/features/maven/types/maven.types.ts b/windows/tauri/src/features/maven/types/maven.types.ts index f4f35a2d7..2c8b6132f 100644 --- a/windows/tauri/src/features/maven/types/maven.types.ts +++ b/windows/tauri/src/features/maven/types/maven.types.ts @@ -186,6 +186,24 @@ export interface MavenSettings { javaHomePath: string; } +export type MavenEffectiveConfigurationStatus = "idle" | "loading" | "ready" | "failed"; + +/** + * What a Maven launch would use for the current settings, plus the values + * machine detection found while ignoring saved overrides. A `null` field means + * detection found nothing, not that the field is disabled. + */ +export interface MavenEffectiveConfiguration { + settingsPath: string | null; + localRepositoryPath: string | null; + mavenExecutablePath: string | null; + javaHomePath: string | null; + detectedSettingsPath: string | null; + detectedLocalRepositoryPath: string | null; + detectedMavenExecutablePath: string | null; + detectedJavaHomePath: string | null; +} + export const MAVEN_LIFECYCLE_PHASES = [ "clean", "validate", diff --git a/windows/tauri/src/features/run/stores/run-maven-context.test.ts b/windows/tauri/src/features/run/stores/run-maven-context.test.ts index 21fa3aca4..eab684105 100644 --- a/windows/tauri/src/features/run/stores/run-maven-context.test.ts +++ b/windows/tauri/src/features/run/stores/run-maven-context.test.ts @@ -85,6 +85,7 @@ describe("Maven-backed Run context", () => { saveWorkspaceBeforeLaunch, startRunProcess, stopRunProcess, + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), }; const store = createRunStore("workspace", dependencies); @@ -146,6 +147,7 @@ describe("Maven-backed Run context", () => { executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), startRunProcess, stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), }; const store = createRunStore("workspace", dependencies); @@ -169,4 +171,99 @@ describe("Maven-backed Run context", () => { }), ]); }); + + test("a configured Maven path overrides the project Maven context", async () => { + const automaticContext: MavenLaunchContext = { + ...mavenContext, + mavenExecutablePath: "D:/project/mvn.cmd", + javaHomePath: "C:/project/jdk", + }; + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const dependencies: RunStoreDependencies = { + createLaunchPlan: mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })), + mavenLaunchContextForWorkspace: mock(async () => automaticContext), + resolveRunLaunch, + saveWorkspaceBeforeLaunch: mock(async () => undefined), + executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), + startRunProcess: mock(async () => undefined), + stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, + prepareJavaRunLaunch: mock(async () => null), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [ + { + ...configuration, + mavenExecutablePath: "D:/legacy/mvn.cmd", + mavenJavaHomePath: "C:/legacy/jdk", + }, + ], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/legacy/mvn.cmd", + mavenJavaHomePath: "C:/legacy/jdk", + }), + ); + }); + + test("an empty run configuration Maven path uses the project Maven context", async () => { + const resolveRunLaunch = mock(async () => ({ + executable: "D:/Tools/mvn.cmd", + workingDirectory: "D:/work/reactor", + environment: {}, + })); + const dependencies: RunStoreDependencies = { + createLaunchPlan: mock(async () => ({ + executable: { toolchain: "project-maven" as const }, + arguments: ["-B", "spring-boot:run"], + workingDirectory: "reactor", + })), + mavenLaunchContextForWorkspace: mock(async () => mavenContext), + resolveRunLaunch, + saveWorkspaceBeforeLaunch: mock(async () => undefined), + executePreLaunchStep: mock(async () => ({ exitCode: 0, output: "" })), + startRunProcess: mock(async () => undefined), + stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, + prepareJavaRunLaunch: mock(async () => null), + }; + const store = createRunStore("workspace", dependencies); + store.setState({ + root: "D:/work", + configurations: [ + { + ...configuration, + mavenExecutablePath: " ", + mavenJavaHomePath: "", + }, + ], + diagnostics: [], + effectiveRuntimeExecutablePaths: {}, + }); + + await store.getState().actions.runConfiguration(configuration.id); + + expect(resolveRunLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + mavenExecutablePath: "D:/Tools/apache-maven", + mavenJavaHomePath: "C:/Java/jdk-21", + }), + ); + }); }); diff --git a/windows/tauri/src/features/run/stores/run-prelaunch.test.ts b/windows/tauri/src/features/run/stores/run-prelaunch.test.ts index 33ade1d0d..aa4f90aed 100644 --- a/windows/tauri/src/features/run/stores/run-prelaunch.test.ts +++ b/windows/tauri/src/features/run/stores/run-prelaunch.test.ts @@ -61,6 +61,7 @@ function standaloneDependencies(overrides: Partial = {}): saveWorkspaceBeforeLaunch: mock(async () => undefined), startRunProcess, stopRunProcess: mock(async () => undefined), + seedMavenLocalConfiguration: () => undefined, prepareJavaRunLaunch: mock(async () => null), javaBuildFailurePolicyForWorkspace: () => "ask", setJavaBuildFailurePolicy: mock(() => undefined), diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 039c7b1a0..379ee3a9c 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -2,7 +2,11 @@ import { createStore } from "zustand/vanilla"; import { saveWorkspaceBeforeLaunch } from "@/features/editor/services/save-workspace-before-launch"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; -import { mavenLaunchContextForWorkspace } from "@/features/maven/stores/maven.store"; +import { + mavenLaunchContextForWorkspace, + useMavenStore, +} from "@/features/maven/stores/maven.store"; +import type { MavenLaunchContext, MavenSettings } from "@/features/maven/types/maven.types"; import { createLaunchPlan, generateRunConfiguration, @@ -161,6 +165,7 @@ export interface RunStoreDependencies { resolveRunLaunch: typeof resolveRunLaunch; executePreLaunchStep: typeof executePreLaunchStep; saveWorkspaceBeforeLaunch: typeof saveWorkspaceBeforeLaunch; + seedMavenLocalConfiguration: (workspaceId: string, settings: Partial) => void; startRunProcess: typeof startRunProcess; stopRunProcess: typeof stopRunProcess; prepareJavaRunLaunch: typeof prepareJavaRunLaunch; @@ -175,12 +180,23 @@ export interface RunStoreDependencies { writeGeneratedRunDocuments?: typeof writeGeneratedRunDocuments; } +// Explicit import of a legacy run toolchain into blank Maven settings. Load and +// launch do not call it: a per-configuration override stays on the run document, +// and project settings are written only when the user saves them. +function seedMavenLocalConfiguration( + workspaceId: string, + settings: Partial, +): void { + useMavenStore.getStore(workspaceId).getState().actions.seedLocalConfiguration(settings); +} + const defaultRunStoreDependencies: RunStoreDependencies = { createLaunchPlan, mavenLaunchContextForWorkspace, resolveRunLaunch, executePreLaunchStep, saveWorkspaceBeforeLaunch, + seedMavenLocalConfiguration, startRunProcess, stopRunProcess, prepareJavaRunLaunch, @@ -296,6 +312,21 @@ function flushStampedOutput(sessionId: string, existing: string): string { return trimOutput(existing + stamperFor(sessionId).flush()); } +// A value written on the run configuration wins. An empty field falls back to +// the Maven project context, matching macOS RunService.effectiveOptions. +function mavenProcessPaths( + mavenContext: MavenLaunchContext | null, + configuration: { mavenExecutablePath: string; mavenJavaHomePath: string }, +) { + const configuredExecutable = configuration.mavenExecutablePath.trim(); + const configuredJavaHome = configuration.mavenJavaHomePath.trim(); + return { + mavenExecutablePath: + configuredExecutable || mavenContext?.mavenExecutablePath || "", + mavenJavaHomePath: configuredJavaHome || mavenContext?.javaHomePath || "", + }; +} + function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { return { javaHomePath: configuration.javaHomePath, @@ -875,10 +906,7 @@ export const createRunStore = ( executable: plan.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - mavenExecutablePath: - configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", - mavenJavaHomePath: - configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", + ...mavenProcessPaths(mavenContext, configuration), runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); @@ -959,10 +987,7 @@ export const createRunStore = ( executable: step.executable, workingDirectory: plan.workingDirectory, javaHomePath: configuration.javaHomePath, - mavenExecutablePath: - configuration.mavenExecutablePath || mavenContext?.mavenExecutablePath || "", - mavenJavaHomePath: - configuration.mavenJavaHomePath || mavenContext?.javaHomePath || "", + ...mavenProcessPaths(mavenContext, configuration), runtimeExecutablePaths: state.effectiveRuntimeExecutablePaths, environment: mergeLaunchEnvironment(configuration.env, plan), }); diff --git a/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx b/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx new file mode 100644 index 000000000..d95eebb85 --- /dev/null +++ b/windows/tauri/src/features/settings/components/keyboard-settings-routing.test.tsx @@ -0,0 +1,201 @@ +import { afterAll, expect, spyOn, test } from "bun:test"; +import { act, forwardRef, type ReactNode } from "react"; +import type { Root } from "react-dom/client"; +import { installHappyDom } from "@/test-utils/happy-dom"; + +import * as animation from "motion/react"; +import * as nativeDialog from "@tauri-apps/plugin-dialog"; +import * as nativeFs from "@tauri-apps/plugin-fs"; +import * as toast from "@/features/layout/contexts/toast-context"; +import { useKeymapStore } from "@/features/keymaps/stores/keymaps.store"; +import { keymapRegistry } from "@/features/keymaps/utils/registry"; +import { getDefaultSettingsSnapshot } from "@/features/settings/config/default-settings"; +import * as persistence from "@/features/settings/lib/settings-persistence"; +import { useSettingsStore } from "@/features/settings/stores/settings.store"; +import { useUIState } from "@/features/window/stores/ui-state.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import * as dialog from "@/ui/dialog"; +import SettingsDialog from "./settings-dialog"; + +// React DOM must detect the DOM before initializing its input event handling. +const restoreDom = installHappyDom(); +afterAll(restoreDom); +const { createRoot } = await import("react-dom/client"); + +test("keyboard settings route supports searching, recording and resetting a command binding", async () => { + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + const previousSettings = useSettingsStore.getState().settings; + const previousKeymaps = useKeymapStore.getState(); + const previousCommands = keymapRegistry.getAllCommands(); + const previousBindings = keymapRegistry.getAllKeybindings(); + const workspaceId = "keyboard-settings-routing-test"; + const save = spyOn(persistence, "debouncedSaveSettingsToStore").mockImplementation(() => {}); + const frame = spyOn(dialog, "default").mockImplementation(({ children }) => ( +
{children}
+ )); + // Test settings behavior without waiting for animation frames or native dialog focus. + const presence = spyOn(animation, "AnimatePresence").mockImplementation(({ children }) => ( + <>{children} + )); + const motionDiv = spyOn(animation.motion, "div").mockImplementation( + forwardRef>(({ children, className }, ref) => ( +
+ {children as ReactNode} +
+ )) as typeof animation.motion.div, + ); + const exportPath = "/test/keybindings.json"; + const exportDialog = spyOn(nativeDialog, "save").mockResolvedValue(exportPath); + const writeFile = spyOn(nativeFs, "writeTextFile").mockResolvedValue(); + const notifications = spyOn(toast, "useToast").mockReturnValue({ + showToast: () => "test-toast", + updateToast: () => {}, + dismissToast: () => {}, + hasToast: () => false, + }); + let importInput: HTMLInputElement | undefined; + const chooseFile = spyOn(HTMLInputElement.prototype, "click").mockImplementation( + function (this: HTMLInputElement) { + importInput = this; + }, + ); + const container = document.createElement("div"); + let root: Root | undefined; + const button = (label: string) => { + const result = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === label || item.getAttribute("aria-label") === label, + ); + expect(result).toBeDefined(); + return result!; + }; + try { + environment.IS_REACT_ACT_ENVIRONMENT = true; + useSettingsStore.setState({ + settings: { ...getDefaultSettingsSnapshot(), keybindingPreset: "none" }, + }); + useKeymapStore.setState({ keybindings: [] }); + keymapRegistry.clear(); + keymapRegistry.registerCommand({ + id: "editor.goToImplementation", + title: "Go to Implementation", + execute: () => {}, + }); + keymapRegistry.registerCommand({ + id: "editor.formatDocument", + title: "Format Document", + execute: () => {}, + }); + keymapRegistry.registerKeybinding({ + key: "ctrl+f12", + command: "editor.goToImplementation", + source: "default", + when: "editorFocus", + }); + useUIState.getStore(workspaceId).setState({ settingsInitialTab: "keyboard" }); + document.body.append(container); + root = createRoot(container); + const mountedRoot = root; + await act(async () => { + mountedRoot.render( + + + {}} /> + + , + ); + }); + await act(async () => button("Open Editor").click()); + expect(button("Import")).toBeDefined(); + expect(button("Export")).toBeDefined(); + expect(container.textContent).toContain("editor.formatDocument"); + const search = container.querySelector("input")!; + expect(search).not.toBeNull(); + const setInputValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + )!.set!; + const searchFor = async (query: string) => { + await act(async () => { + setInputValue.call(search, query); + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + }; + await searchFor("f12"); + expect(container.textContent).toContain("editor.goToImplementation"); + expect(container.textContent).not.toContain("editor.formatDocument"); + await searchFor("implementation"); + const edit = container.querySelector("tbody tr button")!; + await act(async () => edit.click()); + expect(useKeymapStore.getState().recordingCommandId).toBe("editor.goToImplementation"); + await act(async () => { + window.dispatchEvent( + new KeyboardEvent("keydown", { + key: "b", + code: "KeyB", + ctrlKey: true, + altKey: true, + bubbles: true, + }), + ); + }); + await act(async () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + }); + expect(useKeymapStore.getState().keybindings).toEqual([ + { + key: "alt+ctrl+b", + command: "editor.goToImplementation", + source: "user", + enabled: true, + when: "editorFocus", + }, + ]); + expect(useKeymapStore.getState().recordingCommandId).toBeNull(); + await act(async () => button("Export").click()); + expect(writeFile).toHaveBeenCalledTimes(1); + const [path, exported] = writeFile.mock.calls[0]!; + expect(path).toBe(exportPath); + expect(JSON.parse(exported).keybindings).toEqual(useKeymapStore.getState().keybindings); + await searchFor("alt+ctrl+b"); + expect(container.textContent).toContain("editor.goToImplementation"); + await act(async () => button("Reset").click()); + expect(useKeymapStore.getState().keybindings).toEqual([]); + await searchFor("f12"); + expect(container.textContent).toContain("editor.goToImplementation"); + await act(async () => button("Import").click()); + expect(importInput).toBeDefined(); + Object.defineProperty(importInput!, "files", { value: [{ text: async () => exported }] }); + await act(async () => { + await importInput!.onchange!.call(importInput!, { target: importInput } as unknown as Event); + }); + expect(useKeymapStore.getState().keybindings).toEqual(JSON.parse(exported).keybindings); + await searchFor("no-such-command"); + expect(container.querySelectorAll("tbody tr")).toHaveLength(1); + expect(container.textContent).not.toContain("editor.goToImplementation"); + } finally { + try { + await act(async () => root?.unmount()); + } finally { + container.remove(); + chooseFile.mockRestore(); + notifications.mockRestore(); + writeFile.mockRestore(); + exportDialog.mockRestore(); + motionDiv.mockRestore(); + presence.mockRestore(); + frame.mockRestore(); + save.mockRestore(); + useSettingsStore.setState({ settings: previousSettings }); + useKeymapStore.setState(previousKeymaps); + keymapRegistry.clear(); + for (const command of previousCommands) keymapRegistry.registerCommand(command); + for (const binding of previousBindings) keymapRegistry.registerKeybinding(binding); + workspaceRuntimeRegistry.removeWorkspace(workspaceId); + if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + } + } +}); diff --git a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx index e7883ca76..e7ee921dd 100644 --- a/windows/tauri/src/features/settings/components/macos-settings-panels.tsx +++ b/windows/tauri/src/features/settings/components/macos-settings-panels.tsx @@ -21,7 +21,9 @@ import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Switch from "@/ui/switch"; import { LogSettingsPanel } from "./log-settings-panel"; +import { MavenSettingsPanel } from "./tabs/maven-settings-panel"; import { GitSettings } from "./tabs/git-settings"; +import { KeyboardSettings } from "./tabs/keyboard-settings"; import { ProjectEnvironmentSettings } from "./project-environment-settings"; import { RunConfigurationSettings } from "./run-configuration-settings"; @@ -35,6 +37,7 @@ export type MacSettingsCategory = | "keyboard" | "terminal" | "lsp" + | "maven" | "ai" | "ai-commit" | "logs" @@ -335,48 +338,6 @@ function EditorPanel() { ); } -function KeyboardPanel() { - const { t } = useTranslation(); - const settings = useSettingsStore((state) => state.settings); - const updateSetting = useSettingsStore((state) => state.actions.updateSetting); - - return ( -
- - - - - - - -

- {t("settings.mac.shortcutsDescription")} -

-
-
- ); -} - function TerminalPanel() { const { t } = useTranslation(); const settings = useSettingsStore((state) => state.settings); @@ -579,11 +540,13 @@ export function MacSettingsPanel({ case "editor": return ; case "keyboard": - return ; + return ; case "terminal": return ; case "lsp": return ; + case "maven": + return ; case "ai": return ; case "ai-commit": diff --git a/windows/tauri/src/features/settings/components/project-environment-settings.tsx b/windows/tauri/src/features/settings/components/project-environment-settings.tsx index 4e13372cd..48faf9aba 100644 --- a/windows/tauri/src/features/settings/components/project-environment-settings.tsx +++ b/windows/tauri/src/features/settings/components/project-environment-settings.tsx @@ -6,9 +6,10 @@ import { EffectiveToolchain } from "@/features/run/components/effective-toolchai import { useResolvedToolchains } from "@/features/run/hooks/use-resolved-toolchains"; import { toolchainRequirementMessages } from "@/features/run/utils/effective-toolchain"; import type { RunDiagnostic } from "@/features/run/types/run.types"; +import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; import { mavenLaunchContextForWorkspace, useMavenStore } from "@/features/maven/stores/maven.store"; -import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import type { MavenSettings } from "@/features/maven/types/maven.types"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; import { useTranslation } from "@/i18n/locale-provider"; import { Button } from "@/ui/button"; import Input from "@/ui/input"; @@ -43,6 +44,17 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace const mounted = useRef(true); const revision = useRef(0); const saveRevision = useRef(0); + const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const effectiveConfigurationStatus = useMavenStore((state) => state.effectiveConfigurationStatus); + const mavenProject = useMavenStore((state) => state.project); + const resolveEffectiveConfiguration = useMavenStore( + (state) => state.actions.resolveEffectiveConfiguration, + ); + + useEffect(() => { + if (!mavenProject) return; + void resolveEffectiveConfiguration(); + }, [mavenProject, resolveEffectiveConfiguration]); const load = async () => { const current = ++revision.current; @@ -310,6 +322,12 @@ function ProjectEnvironmentForm({ root, workspaceId }: { root: string; workspace + ))} diff --git a/windows/tauri/src/features/settings/components/settings-dialog.tsx b/windows/tauri/src/features/settings/components/settings-dialog.tsx index be4c58a78..26732beb4 100644 --- a/windows/tauri/src/features/settings/components/settings-dialog.tsx +++ b/windows/tauri/src/features/settings/components/settings-dialog.tsx @@ -15,6 +15,7 @@ import { MagicWandIcon, FileTextIcon, FolderIcon, + PackageIcon, TerminalWindowIcon, type Icon, } from "@/ui/icons"; @@ -40,6 +41,7 @@ const categories: CategoryItem[] = [ { id: "keyboard", labelKey: "settings.tabs.keyboard", icon: KeyboardIcon }, { id: "terminal", labelKey: "settings.tabs.terminal", icon: TerminalWindowIcon }, { id: "lsp", labelKey: "settings.tabs.lsp", icon: DatabaseIcon }, + { id: "maven", labelKey: "settings.tabs.maven", icon: PackageIcon }, { id: "ai", labelKey: "settings.tabs.ai", icon: MagicWandIcon }, { id: "ai-commit", labelKey: "settings.tabs.aiCommit", icon: MagicWandIcon }, { id: "git", labelKey: "settings.tabs.git", icon: CodeBlockIcon }, @@ -58,6 +60,7 @@ function categoryFromRequestedTab(tab: SettingsTab | null): MacSettingsCategory case "editor": case "keyboard": case "terminal": + case "maven": case "ai": case "ai-commit": case "logs": @@ -148,9 +151,11 @@ const SettingsDialog = ({ isOpen, onClose }: SettingsDialogProps) => { })} -
-

{t(activeItem.labelKey)}

- +
+

{t(activeItem.labelKey)}

+
+ +
diff --git a/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx new file mode 100644 index 000000000..30f5694e1 --- /dev/null +++ b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.test.tsx @@ -0,0 +1,69 @@ +import { expect, spyOn, test } from "bun:test"; +import * as dialog from "@tauri-apps/plugin-dialog"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { MavenSettingsPanel } from "./maven-settings-panel"; + +test("switching projects with identical saved settings discards the previous project's draft", async () => { + const restoreDom = installHappyDom(); + const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + const previousTabs = useWorkspaceTabsStore.getState().projectTabs; + const workspaceIds = ["maven-settings-draft-a", "maven-settings-draft-b"]; + const browse = spyOn(dialog, "open").mockResolvedValue("D:/team-a/settings.xml"); + const container = document.createElement("div"); + let root: Root | undefined; + try { + environment.IS_REACT_ACT_ENVIRONMENT = true; + for (const id of workspaceIds) { + useMavenStore.getStore(id).setState((state) => ({ + root: `D:/fixture/${id}`, + projectStatus: "ready", + project: { + relativePath: ".", artifactId: id, packaging: "jar", + sourceRoots: [], modules: [], profiles: [], hasWrapper: false, + }, + actions: { ...state.actions, resolveEffectiveConfiguration: async () => {} }, + })); + } + const tabs = workspaceIds.map((id, index) => ({ + id, name: id, path: `D:/fixture/${id}`, isActive: index === 0, lastOpened: 0, + })); + useWorkspaceTabsStore.setState({ projectTabs: tabs }); + document.body.append(container); + root = createRoot(container); + const mountedRoot = root; + await act(async () => { + mountedRoot.render(); + }); + const browseButton = container.querySelector('button[aria-label="Browse"]'); + expect(browseButton).not.toBeNull(); + await act(async () => browseButton!.click()); + expect(container.querySelector("input")?.value).toBe("D:/team-a/settings.xml"); + + await act(async () => { + useWorkspaceTabsStore.setState({ + projectTabs: tabs.map((tab, index) => ({ ...tab, isActive: index === 1 })), + }); + }); + expect(container.querySelector("input")?.value).toBe(""); + expect(useMavenStore.getStore(workspaceIds[1]).getState().settingsPath).toBe(""); + } finally { + try { + await act(async () => root?.unmount()); + } finally { + container.remove(); + browse.mockRestore(); + useWorkspaceTabsStore.setState({ projectTabs: previousTabs }); + for (const id of workspaceIds) workspaceRuntimeRegistry.removeWorkspace(id); + if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + restoreDom(); + } + } +}); diff --git a/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx new file mode 100644 index 000000000..073f18539 --- /dev/null +++ b/windows/tauri/src/features/settings/components/tabs/maven-settings-panel.tsx @@ -0,0 +1,183 @@ +import { open } from "@tauri-apps/plugin-dialog"; +import { useEffect, useState, type ReactNode } from "react"; +import { MavenDetectedValue } from "@/features/maven/components/maven-detected-value"; +import type { MavenSettings } from "@/features/maven/types/maven.types"; +import { useMavenStore } from "@/features/maven/stores/maven.store"; +import { WorkspaceStoreScopeContext } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { useTranslation } from "@/i18n/locale-provider"; +import { Button } from "@/ui/button"; +import { FolderIcon, TrashIcon } from "@/ui/icons"; + +const controlClassName = + "h-8 rounded-md border border-input bg-background px-2.5 text-foreground outline-none focus:border-primary"; + +// The settings dialog renders each category from a self-contained panel; this +// group mirrors the container the sibling panels define. +function SettingsGroup({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

+ {title} +

+
{children}
+
+ ); +} + +interface MavenField { + field: keyof MavenSettings; + label: string; + /** Directories are picked as folders; settings.xml is picked as a file. */ + directory: boolean; +} + +/** + * The Maven page of the application settings. It edits the same project-scoped + * configuration as the Maven tool window, so both surfaces always agree, and it + * shows what automatic detection found on this machine. + * + * The settings dialog sits outside the workbench's workspace provider, so this + * wrapper pins it to the active project tab — the same store the Maven tool + * window uses. + */ +export function MavenSettingsPanel() { + const projectTabId = useWorkspaceTabsStore( + (state) => state.projectTabs.find((tab) => tab.isActive)?.id ?? null, + ); + if (!projectTabId) return ; + return ( + + + + ); +} + +function MavenSettingsForm() { + const { t } = useTranslation(); + const project = useMavenStore((state) => state.project); + const projectStatus = useMavenStore((state) => state.projectStatus); + const settingsPath = useMavenStore((state) => state.settingsPath); + const localRepositoryPath = useMavenStore((state) => state.localRepositoryPath); + const mavenExecutablePath = useMavenStore((state) => state.mavenExecutablePath); + const javaHomePath = useMavenStore((state) => state.javaHomePath); + const configurationSaveError = useMavenStore((state) => state.configurationSaveError); + const effectiveConfiguration = useMavenStore((state) => state.effectiveConfiguration); + const effectiveConfigurationStatus = useMavenStore( + (state) => state.effectiveConfigurationStatus, + ); + const saveLocalConfiguration = useMavenStore((state) => state.actions.saveLocalConfiguration); + const resolveEffectiveConfiguration = useMavenStore( + (state) => state.actions.resolveEffectiveConfiguration, + ); + + useEffect(() => { + void resolveEffectiveConfiguration(); + }, [project, resolveEffectiveConfiguration]); + + const saved: MavenSettings = { + settingsPath, + localRepositoryPath, + mavenExecutablePath, + javaHomePath, + }; + const [draft, setDraft] = useState(saved); + + // The project scan finishes after this page can already be open, and the Maven + // tool window edits the same values, so keep the draft on the saved state. + useEffect(() => { + setDraft({ settingsPath, localRepositoryPath, mavenExecutablePath, javaHomePath }); + }, [settingsPath, localRepositoryPath, mavenExecutablePath, javaHomePath]); + + const dirty = (Object.keys(saved) as Array).some( + (field) => draft[field] !== saved[field], + ); + + const fields: MavenField[] = [ + { field: "settingsPath", label: "settings.xml", directory: false }, + { field: "localRepositoryPath", label: t("maven.localRepository"), directory: true }, + { field: "mavenExecutablePath", label: t("maven.mavenExecutable"), directory: true }, + { field: "javaHomePath", label: t("maven.javaHome"), directory: true }, + ]; + + const browse = async (field: keyof MavenSettings, directory: boolean) => { + const selected = await open({ + directory, + multiple: false, + ...(directory ? {} : { filters: [{ name: "Maven settings", extensions: ["xml"] }] }), + }); + if (typeof selected === "string") setDraft((current) => ({ ...current, [field]: selected })); + }; + + return ( + +

+ {t("maven.settingsDescription")} +

+ {project ? null : ( +

+ {projectStatus === "loading" ? t("maven.scanning") : t("maven.notDetected")} +

+ )} + {fields.map(({ field, label, directory }) => ( + + ))} + {configurationSaveError ? ( +

+ {configurationSaveError} +

+ ) : null} +
+ +
+
+ ); +} diff --git a/windows/tauri/src/features/settings/config/search-index.ts b/windows/tauri/src/features/settings/config/search-index.ts index dc1c5485e..ed73ce805 100644 --- a/windows/tauri/src/features/settings/config/search-index.ts +++ b/windows/tauri/src/features/settings/config/search-index.ts @@ -644,6 +644,40 @@ export const settingsSearchIndex: SettingSearchRecord[] = [ keywords: ["ai", "agent", "chat", "clear", "delete", "history"], }, + // Maven Settings + { + id: "maven-settings-xml", + tab: "maven", + section: "Maven", + label: "settings.xml", + description: "Custom Maven settings.xml used for builds and language tooling", + keywords: ["maven", "settings", "xml", "repository", "mirror", "build"], + }, + { + id: "maven-local-repository", + tab: "maven", + section: "Maven", + label: "Maven Local Repository", + description: "Local Maven repository directory used to cache build artifacts", + keywords: ["maven", "local", "repository", "cache", "artifacts", "repo", "build"], + }, + { + id: "maven-executable", + tab: "maven", + section: "Maven", + label: "Maven Home", + description: "Maven installation directory or executable used to launch builds", + keywords: ["maven", "mvn", "home", "executable", "installation", "build"], + }, + { + id: "maven-jdk-home", + tab: "maven", + section: "Maven", + label: "Maven JDK Home", + description: "JDK used to run Maven builds", + keywords: ["maven", "jdk", "java", "home", "runtime", "build"], + }, + // Keyboard Settings { id: "keyboard-quick-file-open", diff --git a/windows/tauri/src/features/settings/lib/settings-search.ts b/windows/tauri/src/features/settings/lib/settings-search.ts index 31e31c4f3..4ee339467 100644 --- a/windows/tauri/src/features/settings/lib/settings-search.ts +++ b/windows/tauri/src/features/settings/lib/settings-search.ts @@ -26,6 +26,7 @@ export const SETTINGS_SEARCH_TAB_LABELS: Record = { "file-explorer": "Files", git: "Git", terminal: "Terminal", + maven: "Maven", keyboard: "Keybindings", ai: "Agent", "ai-commit": "AI & Commit", diff --git a/windows/tauri/src/features/spring/api/spring-index-api.test.ts b/windows/tauri/src/features/spring/api/spring-index-api.test.ts new file mode 100644 index 000000000..3f47b33f5 --- /dev/null +++ b/windows/tauri/src/features/spring/api/spring-index-api.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { requestSpringIndex } from "./spring-index-api"; +import { classifySpringIndexError } from "../utils/spring-index-error"; + +const executeCore = mock(async (): Promise => ({ + id: "request", + ok: true as const, + data: { + properties: [], + values: [], + propertyReferences: [], + beans: [], + injections: [], + endpoints: [], + }, +})); + +beforeEach(() => { + executeCore.mockClear(); +}); + +describe("requestSpringIndex", () => { + test("preserves endpoint fields and deterministic order", async () => { + const endpoints = [ + { + id: "controller:12:GET:/api/first", + httpMethods: ["GET"], + route: "/api/first", + controller: "FirstController", + method: "first", + path: "src/main/java/com/example/FirstController.java", + line: 12, + column: 3, + }, + { + id: "controller:20:POST:/api/second", + httpMethods: ["POST"], + route: "/api/second", + controller: "SecondController", + method: "second", + path: "src/main/java/com/example/SecondController.java", + line: 20, + column: 3, + }, + ]; + executeCore.mockResolvedValueOnce({ + id: "request", + ok: true, + data: { + properties: [], + values: [], + propertyReferences: [], + beans: [], + injections: [], + endpoints, + }, + }); + + const result = await requestSpringIndex({ + root: "D:/workspace", + paths: ["src/main/java/com/example/FirstController.java"], + refreshDependencyMetadata: false, + }, executeCore); + + expect(result.endpoints).toEqual(endpoints); + }); + + test("normalizes omitted or malformed endpoint collections to an empty array", async () => { + executeCore.mockResolvedValueOnce({ + id: "request", + ok: true, + data: { + properties: [], + values: [], + propertyReferences: [], + beans: [], + injections: [], + }, + }); + const omitted = await requestSpringIndex({ + root: "D:/workspace", + paths: [], + refreshDependencyMetadata: false, + }, executeCore); + expect(omitted.endpoints).toEqual([]); + + executeCore.mockResolvedValueOnce({ + id: "request", + ok: true, + data: { + properties: [], + values: [], + propertyReferences: [], + beans: [], + injections: [], + endpoints: null, + }, + }); + const malformed = await requestSpringIndex({ + root: "D:/workspace", + paths: [], + refreshDependencyMetadata: false, + }, executeCore); + expect(malformed.endpoints).toEqual([]); + }); +}); + +// Match the actual error envelope emitted by Core's Spring root validation. +test("a missing Spring root is classified from the native response", async () => { + executeCore.mockResolvedValueOnce({ + id: "request", ok: false, + error: { code: "invalid_request", message: "Spring index root must be an existing absolute directory" }, + }); + const request = requestSpringIndex({ + root: "D:/fixture/missing", paths: [], refreshDependencyMetadata: false, + }, executeCore); + const failure = await request.catch((error) => classifySpringIndexError(error, "D:/fixture/missing")); + expect(failure).toMatchObject({ category: "rootUnavailable" }); +}); diff --git a/windows/tauri/src/features/spring/api/spring-index-api.ts b/windows/tauri/src/features/spring/api/spring-index-api.ts index b9b141c05..b253cef7e 100644 --- a/windows/tauri/src/features/spring/api/spring-index-api.ts +++ b/windows/tauri/src/features/spring/api/spring-index-api.ts @@ -1,5 +1,6 @@ import { executeCore, type CoreResponse } from "@/core/lithe-core-client"; import type { SpringIndex } from "../types/spring.types"; +import { SpringIndexRequestError } from "../utils/spring-index-error"; interface CoreSpringIndex { properties: Array<{ @@ -46,11 +47,25 @@ interface CoreSpringIndex { qualifier?: string | null; beanIds: string[]; }>; + endpoints: Array<{ + id: string; + httpMethods: string[]; + route: string; + controller: string; + method: string; + path: string; + line: number; + column: number; + }>; } function coreData(response: CoreResponse): T { if (response.ok) return response.data; - throw new Error(`${response.error.code}: ${response.error.message}`); + throw new SpringIndexRequestError( + response.error.code, + response.error.message, + response.error.details, + ); } export async function requestSpringIndex(args: { @@ -59,8 +74,8 @@ export async function requestSpringIndex(args: { metadataRepositories?: string[]; textOverrides?: Record; refreshDependencyMetadata: boolean; -}): Promise { - const response = await executeCore({ +}, execute = executeCore): Promise { + const response = await execute({ id: crypto.randomUUID(), timeoutMilliseconds: args.refreshDependencyMetadata ? 60_000 : 30_000, command: "spring.index", @@ -79,5 +94,6 @@ export async function requestSpringIndex(args: { propertyReferences: data.propertyReferences ?? [], beans: data.beans ?? [], injections: data.injections ?? [], + endpoints: Array.isArray(data.endpoints) ? data.endpoints : [], }; } diff --git a/windows/tauri/src/features/spring/components/spring-endpoints-pane.test.tsx b/windows/tauri/src/features/spring/components/spring-endpoints-pane.test.tsx new file mode 100644 index 000000000..06531026e --- /dev/null +++ b/windows/tauri/src/features/spring/components/spring-endpoints-pane.test.tsx @@ -0,0 +1,207 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { LocaleProvider } from "@/i18n/locale-provider"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { EMPTY_SPRING_INDEX, type SpringIndex, type SpringIndexError } from "../types/spring.types"; +import { useSpringStore } from "../stores/spring.store"; +import { SpringEndpointsPane } from "./spring-endpoints-pane"; + +const WORKSPACE_ID = "workspace-spring-endpoints"; +const ROOT = "C:/work/demo"; + +function endpointIndex(): SpringIndex { + return { + ...EMPTY_SPRING_INDEX, + endpoints: [ + { + id: "users", + httpMethods: ["GET"], + route: "/api/users", + controller: "UserController", + method: "listUsers", + path: "src/main/java/UserController.java", + line: 12, + column: 3, + }, + ], + }; +} + +async function setSpringState(update: { + phase: "idle" | "loading" | "ready" | "failed"; + loadedRoot: string | null; + index?: SpringIndex; + error?: SpringIndexError | null; +}) { + await act(async () => { + workspaceRuntimeRegistry.activateWorkspace( + { id: WORKSPACE_ID, name: "Spring endpoints test" }, + "ready", + ); + useSpringStore.setState({ + requestedRoot: ROOT, + loadedRoot: update.loadedRoot, + root: ROOT, + index: update.index ?? EMPTY_SPRING_INDEX, + phase: update.phase, + error: update.error ?? null, + isIndexing: update.phase === "loading", + }); + useFileSystemStore.setState({ rootFolderPath: ROOT }); + }); +} + +let restoreDom: (() => void) | undefined; +let container: HTMLDivElement; +let root: Root; +let previousActEnvironment: boolean | undefined; + +beforeEach(() => { + restoreDom = installHappyDom(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT; + actEnvironment.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => { + root.unmount(); + }); + container.remove(); + workspaceRuntimeRegistry.resetForTests(); + const actEnvironment = globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + }; + if (previousActEnvironment === undefined) { + delete actEnvironment.IS_REACT_ACT_ENVIRONMENT; + } else { + actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment; + } + restoreDom?.(); +}); + +async function renderPane(): Promise { + await act(async () => { + root.render( + + + , + ); + }); +} + +describe("SpringEndpointsPane", () => { + test("renders initial loading, successful empty, and failure states distinctly", async () => { + await setSpringState({ phase: "loading", loadedRoot: null }); + await renderPane(); + expect(container.textContent).toContain("Indexing Spring project..."); + + await setSpringState({ phase: "ready", loadedRoot: ROOT }); + await renderPane(); + expect(container.textContent).toContain("No Spring MVC endpoints found"); + + await setSpringState({ + phase: "failed", + loadedRoot: null, + error: { category: "rootUnavailable", detail: "C:/private/workspace" }, + }); + await renderPane(); + expect(container.textContent).toContain("The Spring workspace root is unavailable."); + expect(container.textContent).not.toContain("C:/private/workspace"); + }); + + test("renders endpoint rows and keeps them visible during refresh or stale refresh", async () => { + const index = endpointIndex(); + await setSpringState({ phase: "ready", loadedRoot: ROOT, index }); + await renderPane(); + expect(container.textContent).toContain("1 route"); + expect(container.textContent).toContain("/api/users"); + expect(container.textContent).toContain("UserController.listUsers"); + expect(container.textContent).toContain("src/main/java/UserController.java"); + + await setSpringState({ phase: "loading", loadedRoot: ROOT, index }); + await renderPane(); + expect(container.textContent).toContain("/api/users"); + expect(container.textContent).toContain("Updating Spring endpoints..."); + + await setSpringState({ + phase: "failed", + loadedRoot: ROOT, + index, + error: { category: "indexFailed", detail: "refresh failed" }, + }); + await renderPane(); + expect(container.textContent).toContain("/api/users"); + expect(container.textContent).toContain("Showing the last successful Spring index."); + }); + + test("keeps the filter input stable and clears it when the workspace root changes", async () => { + await setSpringState({ phase: "ready", loadedRoot: ROOT, index: endpointIndex() }); + await renderPane(); + + const input = container.querySelector("input") as HTMLInputElement; + expect(input.value).toBe(""); + expect(container.textContent).toContain("/api/users"); + + await act(async () => { + useFileSystemStore.setState({ rootFolderPath: "D:/other/demo" }); + useSpringStore.setState({ + requestedRoot: "D:/other/demo", + loadedRoot: "D:/other/demo", + root: "D:/other/demo", + phase: "ready", + index: endpointIndex(), + }); + }); + await renderPane(); + expect(input.value).toBe(""); + expect(container.textContent).toContain("/api/users"); + }); + + test("opens the indexed controller location when a row is activated", async () => { + await setSpringState({ phase: "ready", loadedRoot: ROOT, index: endpointIndex() }); + const handleFileSelect = mock(async () => "buffer"); + await act(async () => { + useFileSystemStore.setState({ handleFileSelect }); + }); + await renderPane(); + + const row = container.querySelector("button[aria-label^='GET /api/users']"); + expect(row).not.toBeNull(); + await act(async () => { + row?.dispatchEvent(new Event("click", { bubbles: true })); + }); + + expect(handleFileSelect).toHaveBeenCalledWith( + "C:/work/demo/src/main/java/UserController.java", + false, + 12, + 3, + ); + }); + + test("handles source navigation failures without logging host paths", async () => { + await setSpringState({ phase: "ready", loadedRoot: ROOT, index: endpointIndex() }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + useFileSystemStore.setState({ + handleFileSelect: async () => { throw new Error("Cannot read C:/private/workspace/UserController.java"); }, + }); + await renderPane(); + const row = container.querySelector("button[aria-label^='GET /api/users']"); + expect(row).not.toBeNull(); + await act(async () => { row?.dispatchEvent(new Event("click", { bubbles: true })); }); + expect(warn.mock.calls).toEqual([["Failed to open Spring endpoint source"]]); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx b/windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx new file mode 100644 index 000000000..6e96e9575 --- /dev/null +++ b/windows/tauri/src/features/spring/components/spring-endpoints-pane.tsx @@ -0,0 +1,217 @@ +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { PaneChip, paneHeaderClassName, paneTitleClassName } from "@/features/panes/components/pane-chrome"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { useSpringStore } from "../stores/spring.store"; +import type { SpringEndpoint } from "../types/spring.types"; +import { filterSpringEndpoints } from "../utils/spring-endpoint-filter"; +import { + isSpringEndpointsRefreshing, + isSpringEndpointsStale, + resolveSpringEndpointsViewState, +} from "../utils/spring-endpoints-view-state"; +import { springIndexErrorTranslationKey } from "../utils/spring-index-error"; +import { resolveSpringEndpointLocation } from "../utils/spring-navigation"; +import { Empty, EmptyDescription } from "@/ui/empty"; +import { MagnifyingGlassIcon } from "@/ui/icons"; +import Input from "@/ui/input"; +import { ScrollArea } from "@/ui/scroll-area"; +import { Spinner } from "@/ui/spinner"; +import { useTranslation } from "@/i18n/locale-provider"; +import { cn } from "@/utils/cn"; + +const VIRTUALIZATION_THRESHOLD = 200; +const ENDPOINT_ROW_HEIGHT = 32; + +function methodLabel(endpoint: SpringEndpoint): string { + return endpoint.httpMethods.length > 0 ? endpoint.httpMethods.join(",") : "ANY"; +} + +export function SpringEndpointsPane() { + const { t } = useTranslation(); + const workspaceId = useActiveWorkspaceId(); + const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); + const handleFileSelect = useFileSystemStore((state) => state.handleFileSelect); + const phase = useSpringStore.use.phase(); + const loadedRoot = useSpringStore.use.loadedRoot(); + const index = useSpringStore.use.index(); + const error = useSpringStore.use.error(); + const [query, setQuery] = useState(""); + const [selectedEndpointId, setSelectedEndpointId] = useState(null); + const scrollRef = useRef(null); + + const filteredEndpoints = useMemo( + () => filterSpringEndpoints(index.endpoints, query), + [index.endpoints, query], + ); + const useVirtualizerList = filteredEndpoints.length > VIRTUALIZATION_THRESHOLD; + const rowVirtualizer = useVirtualizer({ + count: filteredEndpoints.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ENDPOINT_ROW_HEIGHT, + getItemKey: (index) => filteredEndpoints[index]?.id ?? index, + overscan: 8, + }); + + const viewState = resolveSpringEndpointsViewState({ + phase, + loadedRoot, + endpointCount: index.endpoints.length, + filteredCount: filteredEndpoints.length, + }); + const isInitialLoading = viewState === "loading"; + const isRefreshing = isSpringEndpointsRefreshing(phase, loadedRoot); + const isStale = isSpringEndpointsStale(phase, loadedRoot); + const hasFailedWithoutData = viewState === "failed"; + + useEffect(() => { + setQuery(""); + setSelectedEndpointId(null); + scrollRef.current?.scrollTo({ top: 0 }); + }, [rootFolderPath, workspaceId]); + + useEffect(() => { + setSelectedEndpointId(null); + scrollRef.current?.scrollTo({ top: 0 }); + }, [query]); + + const handleEndpointClick = useCallback( + (endpoint: SpringEndpoint) => { + if (!rootFolderPath) return; + const location = resolveSpringEndpointLocation(endpoint, rootFolderPath); + if (!location) return; + setSelectedEndpointId(endpoint.id); + void handleFileSelect( + location.filePath, + false, + location.line + 1, + location.column + 1, + ).catch(() => { + console.warn("Failed to open Spring endpoint source"); + }); + }, + [handleFileSelect, rootFolderPath], + ); + + const renderEndpointRow = useCallback( + (endpoint: SpringEndpoint) => ( + + ), + [handleEndpointClick, selectedEndpointId], + ); + + const countLabel = + filteredEndpoints.length === 1 + ? t("springEndpoints.routeCountOne", { count: filteredEndpoints.length }) + : t("springEndpoints.routeCount", { count: filteredEndpoints.length }); + + const errorMessageKey = springIndexErrorTranslationKey(error) ?? "springEndpoints.error.indexFailed"; + + return ( +
+
+
+ {t("workbench.springEndpoints")} + {countLabel} +
+
+
+ setQuery(event.target.value)} + placeholder={t("springEndpoints.filterPlaceholder")} + leftIcon={MagnifyingGlassIcon} + aria-label={t("springEndpoints.filterPlaceholder")} + /> +
+ {isRefreshing ? ( +
+ +
+ ) : null} + {isStale ? ( +
+ {t("springEndpoints.stale")} +
+ ) : null} + {isInitialLoading ? ( + + + + ) : null} + {hasFailedWithoutData ? ( + + {t(errorMessageKey)} + + ) : null} + {viewState === "empty" ? ( + + {t("springEndpoints.empty")} + + ) : null} + {viewState === "no-match" ? ( + + {t("springEndpoints.noMatch")} + + ) : null} + {viewState === "list" ? ( + + {useVirtualizerList ? ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const endpoint = filteredEndpoints[virtualRow.index]; + if (!endpoint) return null; + return ( +
+ {renderEndpointRow(endpoint)} +
+ ); + })} +
+ ) : ( +
{filteredEndpoints.map(renderEndpointRow)}
+ )} +
+ ) : null} +
+ ); +} + +export default SpringEndpointsPane; diff --git a/windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx b/windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx new file mode 100644 index 000000000..7596636f9 --- /dev/null +++ b/windows/tauri/src/features/spring/hooks/use-spring-index.test.tsx @@ -0,0 +1,186 @@ +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { installHappyDom } from "@/test-utils/happy-dom"; +import { useSpringStore } from "../stores/spring.store"; +import { EMPTY_SPRING_INDEX, type SpringIndex } from "../types/spring.types"; +import { useSpringIndex, type SpringIndexDependencies } from "./use-spring-index"; + +const WORKSPACE_A = "spring-index-hook-a"; +const WORKSPACE_B = "spring-index-hook-b"; +const ROOT_A = "C:/fixture/a"; +const ROOT_B = "D:/fixture/b"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { resolve = complete; }); + return { promise, resolve }; +} + +function files(root: string) { + return [{ name: "Controller.java", path: `${root}/src/Controller.java`, isDir: false }]; +} + +function index(root: string): SpringIndex { + return { + ...EMPTY_SPRING_INDEX, + endpoints: [{ + id: root, httpMethods: ["GET"], route: "/fixture", controller: "Controller", + method: "get", path: "src/Controller.java", line: 1, column: 1, + }], + }; +} + +let scheduled: Array<{ reload: () => void; cancelled: boolean }>; +let dependencies: SpringIndexDependencies; +let requestIndex: ReturnType>; +let container: HTMLDivElement; +let root: Root; +let restoreDom: () => void; +const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; +let previousAct: boolean | undefined; + +function Probe() { + useSpringIndex(dependencies); + return null; +} + +function prepareWorkspace(id: string, path: string) { + workspaceRuntimeRegistry.ensureWorkspace({ id, name: id }, "ready"); + const store = useFileSystemStore.getStore(id); + store.setState({ rootFolderPath: path, getAllProjectFiles: async () => files(path) }); + return store; +} + +beforeEach(() => { + restoreDom = installHappyDom(); + previousAct = environment.IS_REACT_ACT_ENVIRONMENT; + environment.IS_REACT_ACT_ENVIRONMENT = true; + scheduled = []; + requestIndex = mock(async (args) => index(args.root)); + dependencies = { + requestIndex, + resolveMetadataRepository: async () => undefined, + scheduleReload: (reload) => { + const entry = { reload, cancelled: false }; + scheduled.push(entry); + return () => { entry.cancelled = true; }; + }, + }; + prepareWorkspace(WORKSPACE_A, ROOT_A); + prepareWorkspace(WORKSPACE_B, ROOT_B); + workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_A, name: "A" }, "ready"); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + try { + await act(async () => root.unmount()); + } finally { + container.remove(); + workspaceRuntimeRegistry.resetForTests(); + if (previousAct === undefined) delete environment.IS_REACT_ACT_ENVIRONMENT; + else environment.IS_REACT_ACT_ENVIRONMENT = previousAct; + restoreDom(); + } +}); + +async function mount() { + await act(async () => { root.render(); }); +} + +async function activateB() { + await act(async () => { + workspaceRuntimeRegistry.activateWorkspace({ id: WORKSPACE_B, name: "B" }, "ready"); + }); +} + +function externalChange(path: string, event_type: string) { + window.dispatchEvent(new window.CustomEvent("file-external-change", { detail: { path, event_type } })); +} + +test("a pending file scan is abandoned before starting native indexing after switching projects", async () => { + const scan = deferred>(); + useFileSystemStore.getStore(WORKSPACE_A).setState({ getAllProjectFiles: () => scan.promise }); + try { + await mount(); + expect(useSpringStore.getStore(WORKSPACE_A).getState().phase).toBe("loading"); + await activateB(); + await act(async () => { scan.resolve(files(ROOT_A)); }); + expect(requestIndex.mock.calls.map(([args]) => args.root)).toEqual([ROOT_B]); + expect(useSpringStore.getStore(WORKSPACE_B).getState().index).toEqual(index(ROOT_B)); + } finally { + await act(async () => { scan.resolve(files(ROOT_A)); }); + } +}); + +test("a late native result cannot replace another project's endpoints or recreate a closed workspace", async () => { + const result = deferred(); + requestIndex.mockImplementationOnce(() => result.promise); + try { + await mount(); + expect(requestIndex).toHaveBeenCalledTimes(1); + await activateB(); + workspaceRuntimeRegistry.removeWorkspace(WORKSPACE_A); + await act(async () => { result.resolve(index(ROOT_A)); }); + expect(workspaceRuntimeRegistry.hasWorkspace(WORKSPACE_A)).toBe(false); + expect(useSpringStore.getStore(WORKSPACE_B).getState().index).toEqual(index(ROOT_B)); + } finally { + await act(async () => { result.resolve(index(ROOT_A)); }); + } +}); + +test("workspace identity changes restart indexing even when the root string is unchanged", async () => { + prepareWorkspace(WORKSPACE_B, ROOT_A); + await mount(); + await activateB(); + expect(requestIndex).toHaveBeenCalledTimes(2); + expect(useSpringStore.getStore(WORKSPACE_B).getState().phase).toBe("ready"); + expect(useSpringStore.getStore(WORKSPACE_B).getState().index).toEqual(index(ROOT_A)); +}); + +test("directory renames and removals refresh endpoints while unrelated workspaces stay ignored", async () => { + await mount(); + requestIndex.mockClear(); + let currentFiles = files(ROOT_A); + useFileSystemStore.getStore(WORKSPACE_A).setState({ getAllProjectFiles: async () => currentFiles }); + + await act(async () => { + externalChange(`${ROOT_B}/src`, "deleted"); + externalChange(`${ROOT_A}/README.md`, "reloaded"); + }); + expect(scheduled).toHaveLength(0); + + // The watcher emits only directory paths for the two sides of a rename. + currentFiles = [{ name: "Controller.java", path: `${ROOT_A}/renamed/Controller.java`, isDir: false }]; + await act(async () => { + externalChange(`${ROOT_A}/src`, "deleted"); + externalChange(`${ROOT_A}/renamed`, "opened"); + }); + expect(scheduled.map((entry) => entry.cancelled)).toEqual([true, false]); + await act(async () => { scheduled[1].reload(); }); + expect(requestIndex.mock.calls[0][0].paths).toEqual(["renamed/Controller.java"]); + + currentFiles = []; + await act(async () => { externalChange(`${ROOT_A}/renamed`, "deleted"); }); + await act(async () => { scheduled[2].reload(); }); + expect(useSpringStore.getStore(WORKSPACE_A).getState().index.endpoints).toEqual([]); + expect(useSpringStore.getStore(WORKSPACE_A).getState().phase).toBe("ready"); +}); + +test("a workspace rescan is coalesced and pending reloads are cancelled on unmount", async () => { + await mount(); + await act(async () => { + externalChange(ROOT_A, "rescan"); + externalChange(ROOT_A, "rescan"); + }); + expect(scheduled.map((entry) => entry.cancelled)).toEqual([true, false]); + await act(async () => { root.render(null); }); + expect(scheduled[1].cancelled).toBe(true); + externalChange(ROOT_A, "rescan"); + expect(scheduled).toHaveLength(2); +}); diff --git a/windows/tauri/src/features/spring/hooks/use-spring-index.ts b/windows/tauri/src/features/spring/hooks/use-spring-index.ts index 813846850..749fc6b45 100644 --- a/windows/tauri/src/features/spring/hooks/use-spring-index.ts +++ b/windows/tauri/src/features/spring/hooks/use-spring-index.ts @@ -1,17 +1,22 @@ import { exists } from "@tauri-apps/plugin-fs"; import { homeDir, join } from "@tauri-apps/api/path"; -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useBufferStore } from "@/features/editor/stores/buffer.store"; import { useFileSystemStore } from "@/features/file-system/stores/file-system.store"; import { hasTextContent } from "@/features/panes/types/pane-content.types"; +import { useActiveWorkspaceId } from "@/features/workspace/stores/create-workspace-scoped-store"; +import { pathStartsWithRoot } from "@/utils/path-helpers"; import { requestSpringIndex } from "../api/spring-index-api"; import { useSpringStore } from "../stores/spring.store"; import { EMPTY_SPRING_INDEX } from "../types/spring.types"; +import { classifySpringIndexError } from "../utils/spring-index-error"; import { collectSpringIndexPaths, isSpringIndexPath, + shouldScheduleSpringReloadForExternalChange, workspaceRelativeSpringPath, } from "../utils/spring-index-paths"; +import { isSupportedSpringRoot } from "../utils/spring-root"; const RELOAD_DELAY_MS = 300; @@ -25,42 +30,72 @@ async function resolveMavenMetadataRepository(): Promise { return undefined; } -export function useSpringIndex() { +export interface SpringIndexDependencies { + requestIndex: typeof requestSpringIndex; + resolveMetadataRepository: typeof resolveMavenMetadataRepository; + scheduleReload: (reload: () => void) => () => void; +} + +const defaultDependencies: SpringIndexDependencies = { + requestIndex: requestSpringIndex, + resolveMetadataRepository: resolveMavenMetadataRepository, + scheduleReload: (reload) => { + const timer = setTimeout(reload, RELOAD_DELAY_MS); + return () => clearTimeout(timer); + }, +}; + +export function useSpringIndex(dependencies: SpringIndexDependencies = defaultDependencies) { + const workspaceId = useActiveWorkspaceId(); const rootFolderPath = useFileSystemStore((state) => state.rootFolderPath); - const loadGeneration = useRef(0); - const reloadTimer = useRef | undefined>(undefined); useEffect(() => { - const store = useSpringStore.getState(); + // Capture the owning stores before awaiting; active-workspace accessors can + // point at a different project by the time a scan or native request returns. + const store = useSpringStore.getStore(workspaceId).getState(); + const fileSystemStore = useFileSystemStore.getStore(workspaceId); + const bufferStore = useBufferStore.getStore(workspaceId); if (!rootFolderPath) { store.actions.reset(); return; } let cancelled = false; + let cancelReload: (() => void) | undefined; const load = async (refreshDependencyMetadata: boolean) => { const generation = store.actions.beginLoad(rootFolderPath); - loadGeneration.current = generation; + if (!isSupportedSpringRoot(rootFolderPath)) { + store.actions.failLoad( + generation, + classifySpringIndexError( + new Error("Spring indexing is unavailable for this workspace root"), + rootFolderPath, + ), + ); + return; + } try { - const files = await useFileSystemStore.getState().getAllProjectFiles(); + const files = await fileSystemStore.getState().getAllProjectFiles(); + if (cancelled) return; const paths = collectSpringIndexPaths( files.map((file) => file.path), rootFolderPath, ); const textOverrides: Record = {}; - for (const buffer of useBufferStore.getState().buffers) { + for (const buffer of bufferStore.getState().buffers) { if (!buffer.path || !hasTextContent(buffer) || !isSpringIndexPath(buffer.path)) continue; const relative = workspaceRelativeSpringPath(buffer.path, rootFolderPath); if (relative) textOverrides[relative] = buffer.content; } const metadataRepository = refreshDependencyMetadata - ? await resolveMavenMetadataRepository() + ? await dependencies.resolveMetadataRepository() : undefined; + if (cancelled) return; const index = paths.length === 0 ? EMPTY_SPRING_INDEX - : await requestSpringIndex({ + : await dependencies.requestIndex({ root: rootFolderPath, paths, metadataRepositories: metadataRepository ? [metadataRepository] : [], @@ -68,23 +103,27 @@ export function useSpringIndex() { refreshDependencyMetadata, }); if (cancelled) return; - useSpringStore.getState().actions.completeLoad(generation, rootFolderPath, index); + store.actions.completeLoad(generation, rootFolderPath, index); } catch (error) { - console.warn("Spring index failed:", error); - if (!cancelled) useSpringStore.getState().actions.failLoad(generation); + if (!cancelled) { + const failure = classifySpringIndexError(error, rootFolderPath); + console.warn("Spring index failed:", failure.category); + store.actions.failLoad(generation, failure); + } } }; const scheduleReload = () => { - if (reloadTimer.current) clearTimeout(reloadTimer.current); - reloadTimer.current = setTimeout(() => { + cancelReload?.(); + cancelReload = dependencies.scheduleReload(() => { + cancelReload = undefined; void load(false); - }, RELOAD_DELAY_MS); + }); }; void load(true); - const unsubscribeBuffers = useBufferStore.subscribe((state, previous) => { + const unsubscribeBuffers = bufferStore.subscribe((state, previous) => { const changed = state.buffers.some((buffer) => { if (!buffer.path || !isSpringIndexPath(buffer.path) || !hasTextContent(buffer)) return false; const previousBuffer = previous.buffers.find((candidate) => candidate.id === buffer.id); @@ -94,8 +133,12 @@ export function useSpringIndex() { }); const handleExternalChange = (event: Event) => { - const path = (event as CustomEvent<{ path?: string }>).detail?.path; - if (path && isSpringIndexPath(path)) scheduleReload(); + const detail = (event as CustomEvent<{ path?: string; event_type?: string }>).detail; + if (!detail?.path || !detail.event_type) return; + if (!pathStartsWithRoot(detail.path, rootFolderPath)) return; + if (shouldScheduleSpringReloadForExternalChange(detail.event_type, detail.path)) { + scheduleReload(); + } }; window.addEventListener("file-external-change", handleExternalChange); @@ -103,7 +146,7 @@ export function useSpringIndex() { cancelled = true; unsubscribeBuffers(); window.removeEventListener("file-external-change", handleExternalChange); - if (reloadTimer.current) clearTimeout(reloadTimer.current); + cancelReload?.(); }; - }, [rootFolderPath]); + }, [workspaceId, rootFolderPath, dependencies]); } diff --git a/windows/tauri/src/features/spring/stores/spring.store.test.ts b/windows/tauri/src/features/spring/stores/spring.store.test.ts new file mode 100644 index 000000000..c168e18a6 --- /dev/null +++ b/windows/tauri/src/features/spring/stores/spring.store.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { workspaceRuntimeRegistry } from "@/features/workspace/runtime/workspace-runtime-registry"; +import { EMPTY_SPRING_INDEX, type SpringIndex } from "../types/spring.types"; +import { useSpringStore } from "./spring.store"; + +const WORKSPACE_ID = "workspace-a"; + +function store() { + return useSpringStore.getStore(WORKSPACE_ID); +} + +function endpointIndex(route: string): SpringIndex { + return { + ...EMPTY_SPRING_INDEX, + endpoints: [ + { + id: route, + httpMethods: ["GET"], + route, + controller: "DemoController", + method: "demo", + path: "src/main/java/DemoController.java", + line: 1, + column: 1, + }, + ], + }; +} + +afterEach(() => { + workspaceRuntimeRegistry.resetForTests(); +}); + +describe("Spring store lifecycle", () => { + test("starts idle and records successful endpoint indexes", () => { + expect(store().getState().phase).toBe("idle"); + const generation = store().getState().actions.beginLoad("C:/work/demo"); + expect(store().getState().phase).toBe("loading"); + expect(store().getState().isIndexing).toBe(true); + + const index = endpointIndex("/api/demo"); + store().getState().actions.completeLoad(generation, "C:/work/demo", index); + + expect(store().getState().phase).toBe("ready"); + expect(store().getState().loadedRoot).toBe("C:/work/demo"); + expect(store().getState().index.endpoints).toEqual(index.endpoints); + expect(store().getState().error).toBeNull(); + }); + + test("keeps a same-root refresh's successful data while loading", () => { + const firstGeneration = store().getState().actions.beginLoad("C:/work/demo"); + store() + .getState() + .actions.completeLoad(firstGeneration, "C:/work/demo", endpointIndex("/api/old")); + + const refreshGeneration = store().getState().actions.beginLoad("c:/work/demo/"); + expect(refreshGeneration).toBeGreaterThan(firstGeneration); + expect(store().getState().phase).toBe("loading"); + expect(store().getState().loadedRoot).toBe("C:/work/demo"); + expect(store().getState().index.endpoints).toHaveLength(1); + }); + + test("clears applied data when the logical root changes", () => { + const firstGeneration = store().getState().actions.beginLoad("C:/work/demo"); + store() + .getState() + .actions.completeLoad(firstGeneration, "C:/work/demo", endpointIndex("/api/old")); + + store().getState().actions.beginLoad("D:/other/demo"); + expect(store().getState().phase).toBe("loading"); + expect(store().getState().loadedRoot).toBeNull(); + expect(store().getState().index.endpoints).toEqual([]); + }); + + test("distinguishes first-load failure from a successful empty result", () => { + const generation = store().getState().actions.beginLoad("C:/work/demo"); + store().getState().actions.failLoad(generation, { + category: "rootUnavailable", + detail: "missing", + }); + + expect(store().getState().phase).toBe("failed"); + expect(store().getState().loadedRoot).toBeNull(); + expect(store().getState().error?.category).toBe("rootUnavailable"); + }); + + test("retains the last successful result when a refresh fails", () => { + const firstGeneration = store().getState().actions.beginLoad("C:/work/demo"); + const index = endpointIndex("/api/demo"); + store().getState().actions.completeLoad(firstGeneration, "C:/work/demo", index); + + const refreshGeneration = store().getState().actions.beginLoad("C:/work/demo"); + store().getState().actions.failLoad(refreshGeneration, { + category: "indexFailed", + detail: "refresh failed", + }); + + expect(store().getState().phase).toBe("failed"); + expect(store().getState().loadedRoot).toBe("C:/work/demo"); + expect(store().getState().index.endpoints).toEqual(index.endpoints); + expect(store().getState().error?.detail).toBe("refresh failed"); + }); + + test("rejects stale completions and resets to idle", () => { + const firstGeneration = store().getState().actions.beginLoad("C:/work/demo"); + const secondGeneration = store().getState().actions.beginLoad("C:/work/demo"); + store() + .getState() + .actions.completeLoad(firstGeneration, "C:/work/demo", endpointIndex("/api/stale")); + expect(store().getState().index.endpoints).toEqual([]); + + store() + .getState() + .actions.completeLoad(secondGeneration, "C:/work/demo", endpointIndex("/api/current")); + expect(store().getState().index.endpoints[0]?.route).toBe("/api/current"); + + store().getState().actions.reset(); + expect(store().getState().phase).toBe("idle"); + expect(store().getState().requestedRoot).toBeNull(); + expect(store().getState().loadedRoot).toBeNull(); + expect(store().getState().index.endpoints).toEqual([]); + }); +}); diff --git a/windows/tauri/src/features/spring/stores/spring.store.ts b/windows/tauri/src/features/spring/stores/spring.store.ts index 95570b4de..87732618a 100644 --- a/windows/tauri/src/features/spring/stores/spring.store.ts +++ b/windows/tauri/src/features/spring/stores/spring.store.ts @@ -1,52 +1,88 @@ import { createStore } from "zustand/vanilla"; import { createWorkspaceScopedStore } from "@/features/workspace/stores/create-workspace-scoped-store"; -import { EMPTY_SPRING_INDEX, type SpringIndex } from "../types/spring.types"; +import { + EMPTY_SPRING_INDEX, + type SpringIndex, + type SpringIndexError, + type SpringIndexPhase, +} from "../types/spring.types"; +import { isSameSpringRoot } from "../utils/spring-root"; interface SpringState { + requestedRoot: string | null; + loadedRoot: string | null; root: string | null; index: SpringIndex; + phase: SpringIndexPhase; + error: SpringIndexError | null; isIndexing: boolean; generation: number; actions: { beginLoad: (root: string) => number; completeLoad: (generation: number, root: string, index: SpringIndex) => void; - failLoad: (generation: number) => void; + failLoad: (generation: number, error: SpringIndexError) => void; reset: () => void; }; } const createSpringStore = () => createStore()((set, get) => ({ + requestedRoot: null, + loadedRoot: null, root: null, index: EMPTY_SPRING_INDEX, + phase: "idle", + error: null, isIndexing: false, generation: 0, actions: { beginLoad: (root) => { const generation = get().generation + 1; + const rootChanged = !isSameSpringRoot(get().requestedRoot, root); set({ + requestedRoot: root, root, generation, + phase: "loading", + error: null, isIndexing: true, + ...(rootChanged + ? { + loadedRoot: null, + index: EMPTY_SPRING_INDEX, + } + : {}), }); return generation; }, completeLoad: (generation, root, index) => { if (get().generation !== generation) return; set({ + requestedRoot: root, + loadedRoot: root, root, index, + phase: "ready", + error: null, isIndexing: false, }); }, - failLoad: (generation) => { + failLoad: (generation, error) => { if (get().generation !== generation) return; - set({ isIndexing: false }); + set({ + phase: "failed", + error, + isIndexing: false, + }); }, reset: () => { set({ + requestedRoot: null, + loadedRoot: null, root: null, index: EMPTY_SPRING_INDEX, + phase: "idle", + error: null, isIndexing: false, generation: get().generation + 1, }); diff --git a/windows/tauri/src/features/spring/types/spring.types.ts b/windows/tauri/src/features/spring/types/spring.types.ts index f755ca48f..18ce1be14 100644 --- a/windows/tauri/src/features/spring/types/spring.types.ts +++ b/windows/tauri/src/features/spring/types/spring.types.ts @@ -47,12 +47,38 @@ export interface SpringInjection { beanIds: string[]; } +export interface SpringEndpoint { + id: string; + httpMethods: string[]; + route: string; + controller: string; + method: string; + path: string; + line: number; + column: number; +} + +export type SpringIndexPhase = "idle" | "loading" | "ready" | "failed"; + +export type SpringIndexErrorCategory = + | "unsupportedRoot" + | "rootUnavailable" + | "permissionDenied" + | "indexTimeout" + | "indexFailed"; + +export interface SpringIndexError { + category: SpringIndexErrorCategory; + detail: string; +} + export interface SpringIndex { properties: SpringProperty[]; values: SpringConfigurationValue[]; propertyReferences: SpringPropertyReference[]; beans: SpringBean[]; injections: SpringInjection[]; + endpoints: SpringEndpoint[]; } export interface SpringNavigationLocation { @@ -68,4 +94,5 @@ export const EMPTY_SPRING_INDEX: SpringIndex = { propertyReferences: [], beans: [], injections: [], + endpoints: [], }; diff --git a/windows/tauri/src/features/spring/utils/spring-endpoint-filter.test.ts b/windows/tauri/src/features/spring/utils/spring-endpoint-filter.test.ts new file mode 100644 index 000000000..ece627f47 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-endpoint-filter.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import type { SpringEndpoint } from "../types/spring.types"; +import { filterSpringEndpoints } from "./spring-endpoint-filter"; + +const endpoints: SpringEndpoint[] = [ + { + id: "users", + httpMethods: ["GET"], + route: "/api/users", + controller: "UserController", + method: "listUsers", + path: "src/main/java/UserController.java", + line: 20, + column: 3, + }, + { + id: "orders", + httpMethods: ["POST"], + route: "/api/orders", + controller: "OrderController", + method: "createOrder", + path: "src/main/java/OrderController.java", + line: 30, + column: 3, + }, +]; + +describe("filterSpringEndpoints", () => { + test("matches route, controller, method, and HTTP method", () => { + expect(filterSpringEndpoints(endpoints, "users")).toEqual([endpoints[0]]); + expect(filterSpringEndpoints(endpoints, "OrderController")).toEqual([endpoints[1]]); + expect(filterSpringEndpoints(endpoints, "createOrder")).toEqual([endpoints[1]]); + expect(filterSpringEndpoints(endpoints, "post")).toEqual([endpoints[1]]); + }); + + test("matches HTTP method sets joined with a space", () => { + const multiMethod = { + ...endpoints[0], + id: "multi", + httpMethods: ["GET", "POST"], + route: "/api/multi", + }; + expect(filterSpringEndpoints([multiMethod], "get post")).toEqual([multiMethod]); + }); + + test("is case-insensitive and trims the query", () => { + expect(filterSpringEndpoints(endpoints, " UsErS ")).toEqual([endpoints[0]]); + }); + + test("returns all endpoints for a blank query and none for no match", () => { + expect(filterSpringEndpoints(endpoints, " ")).toEqual(endpoints); + expect(filterSpringEndpoints(endpoints, "missing")).toEqual([]); + }); +}); diff --git a/windows/tauri/src/features/spring/utils/spring-endpoint-filter.ts b/windows/tauri/src/features/spring/utils/spring-endpoint-filter.ts new file mode 100644 index 000000000..61d62ae56 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-endpoint-filter.ts @@ -0,0 +1,18 @@ +import type { SpringEndpoint } from "../types/spring.types"; + +export function filterSpringEndpoints( + endpoints: readonly SpringEndpoint[], + query: string, +): SpringEndpoint[] { + const value = query.trim().toLowerCase(); + if (!value) return [...endpoints]; + return endpoints.filter((endpoint) => { + const searchableFields = [ + endpoint.route, + endpoint.controller, + endpoint.method, + endpoint.httpMethods.join(" "), + ]; + return searchableFields.some((field) => field.toLowerCase().includes(value)); + }); +} diff --git a/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.test.ts b/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.test.ts new file mode 100644 index 000000000..1adde1a29 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { + isSpringEndpointsRefreshing, + isSpringEndpointsStale, + resolveSpringEndpointsViewState, +} from "./spring-endpoints-view-state"; + +describe("resolveSpringEndpointsViewState", () => { + test("distinguishes initial loading from a refresh with retained rows", () => { + expect( + resolveSpringEndpointsViewState({ + phase: "loading", + loadedRoot: null, + endpointCount: 0, + filteredCount: 0, + }), + ).toBe("loading"); + expect( + resolveSpringEndpointsViewState({ + phase: "loading", + loadedRoot: "C:/work/demo", + endpointCount: 2, + filteredCount: 1, + }), + ).toBe("list"); + expect(isSpringEndpointsRefreshing("loading", "C:/work/demo")).toBe(true); + }); + + test("does not turn a first-load failure into an empty success", () => { + expect( + resolveSpringEndpointsViewState({ + phase: "failed", + loadedRoot: null, + endpointCount: 0, + filteredCount: 0, + }), + ).toBe("failed"); + expect(isSpringEndpointsStale("failed", null)).toBe(false); + }); + + test("prioritizes empty and no-match after data exists", () => { + expect( + resolveSpringEndpointsViewState({ + phase: "ready", + loadedRoot: "C:/work/demo", + endpointCount: 0, + filteredCount: 0, + }), + ).toBe("empty"); + expect( + resolveSpringEndpointsViewState({ + phase: "ready", + loadedRoot: "C:/work/demo", + endpointCount: 2, + filteredCount: 0, + }), + ).toBe("no-match"); + }); + + test("keeps stale rows eligible while marking the failed refresh", () => { + expect( + resolveSpringEndpointsViewState({ + phase: "failed", + loadedRoot: "C:/work/demo", + endpointCount: 2, + filteredCount: 0, + }), + ).toBe("no-match"); + expect(isSpringEndpointsStale("failed", "C:/work/demo")).toBe(true); + }); +}); diff --git a/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.ts b/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.ts new file mode 100644 index 000000000..33ce65621 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-endpoints-view-state.ts @@ -0,0 +1,37 @@ +import type { SpringIndexPhase } from "../types/spring.types"; + +export type SpringEndpointsViewState = "loading" | "failed" | "empty" | "no-match" | "list"; + +interface SpringEndpointsViewStateInput { + phase: SpringIndexPhase; + loadedRoot: string | null; + endpointCount: number; + filteredCount: number; +} + +export function resolveSpringEndpointsViewState({ + phase, + loadedRoot, + endpointCount, + filteredCount, +}: SpringEndpointsViewStateInput): SpringEndpointsViewState { + if (phase === "loading" && loadedRoot === null) return "loading"; + if (phase === "failed" && loadedRoot === null) return "failed"; + if (endpointCount === 0) return "empty"; + if (filteredCount === 0) return "no-match"; + return "list"; +} + +export function isSpringEndpointsRefreshing( + phase: SpringIndexPhase, + loadedRoot: string | null, +): boolean { + return phase === "loading" && loadedRoot !== null; +} + +export function isSpringEndpointsStale( + phase: SpringIndexPhase, + loadedRoot: string | null, +): boolean { + return phase === "failed" && loadedRoot !== null; +} diff --git a/windows/tauri/src/features/spring/utils/spring-index-error.test.ts b/windows/tauri/src/features/spring/utils/spring-index-error.test.ts new file mode 100644 index 000000000..afa8dd3b8 --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-index-error.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { + classifySpringIndexError, + springIndexErrorTranslationKey, + SpringIndexRequestError, +} from "./spring-index-error"; + +describe("classifySpringIndexError", () => { + test("classifies unsupported roots before inspecting the request error", () => { + const error = classifySpringIndexError(new Error("raw detail"), "wsl://Ubuntu/home/demo"); + expect(error.category).toBe("unsupportedRoot"); + expect(error.detail).toBe("raw detail"); + }); + + test("maps stable Core error codes to user-facing categories", () => { + expect( + classifySpringIndexError( + new SpringIndexRequestError("workspace_not_found", "missing"), + "C:/work/demo", + ).category, + ).toBe("rootUnavailable"); + expect( + classifySpringIndexError( + new SpringIndexRequestError("permission_denied", "denied"), + "C:/work/demo", + ).category, + ).toBe("permissionDenied"); + expect( + classifySpringIndexError( + new SpringIndexRequestError("timed_out", "slow"), + "C:/work/demo", + ).category, + ).toBe("indexTimeout"); + expect( + classifySpringIndexError( + new SpringIndexRequestError("unknown", "failed"), + "C:/work/demo", + ).category, + ).toBe("indexFailed"); + }); + + test("keeps raw details out of the UI translation key", () => { + const error = classifySpringIndexError( + new SpringIndexRequestError("permission_denied", "C:/private/workspace"), + "C:/private/workspace", + ); + expect(springIndexErrorTranslationKey(error)).toBe( + "springEndpoints.error.permissionDenied", + ); + expect(springIndexErrorTranslationKey(error)).not.toContain("C:/private/workspace"); + }); +}); diff --git a/windows/tauri/src/features/spring/utils/spring-index-error.ts b/windows/tauri/src/features/spring/utils/spring-index-error.ts new file mode 100644 index 000000000..e0763bf4f --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-index-error.ts @@ -0,0 +1,52 @@ +import type { SpringIndexError } from "../types/spring.types"; +import { isSupportedSpringRoot } from "./spring-root"; + +export class SpringIndexRequestError extends Error { + readonly code: string; + readonly details?: string; + + constructor(code: string, message: string, details?: string) { + super(message); + this.name = "SpringIndexRequestError"; + this.code = code; + this.details = details; + } +} + +function errorDetail(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function classifySpringIndexError( + error: unknown, + root: string | null | undefined, +): SpringIndexError { + const detail = errorDetail(error); + if (!isSupportedSpringRoot(root)) { + return { + category: "unsupportedRoot", + detail, + }; + } + if (error instanceof SpringIndexRequestError) { + // spring.index currently rejects an unavailable root as invalid_request. + // Accept its validation code as well as the shared missing-workspace code. + if (error.code === "workspace_not_found" || error.code === "invalid_request") { + return { category: "rootUnavailable", detail }; + } + if (error.code === "permission_denied") { + return { category: "permissionDenied", detail }; + } + if (error.code === "timed_out") { + return { category: "indexTimeout", detail }; + } + } + return { + category: "indexFailed", + detail, + }; +} + +export function springIndexErrorTranslationKey(error: SpringIndexError | null): string | null { + return error ? `springEndpoints.error.${error.category}` : null; +} diff --git a/windows/tauri/src/features/spring/utils/spring-index-paths.ts b/windows/tauri/src/features/spring/utils/spring-index-paths.ts index e693cb63c..4391f3883 100644 --- a/windows/tauri/src/features/spring/utils/spring-index-paths.ts +++ b/windows/tauri/src/features/spring/utils/spring-index-paths.ts @@ -16,6 +16,16 @@ export function isSpringConfigurationPath(filePath: string): boolean { return name.startsWith("application-") && (name.endsWith(".yml") || name.endsWith(".yaml")); } +export function shouldScheduleSpringReloadForExternalChange( + eventType: string, + filePath: string, +): boolean { + // The watcher reports a renamed/deleted directory as a single path. Its + // children can contain controllers even when that path has no Java suffix. + return eventType === "rescan" || eventType === "opened" || eventType === "deleted" + || isSpringIndexPath(filePath); +} + export function workspaceRelativeSpringPath(filePath: string, root: string): string { return getRelativePath(filePath, root).replace(/\\/g, "/"); } diff --git a/windows/tauri/src/features/spring/utils/spring-navigation.test.ts b/windows/tauri/src/features/spring/utils/spring-navigation.test.ts index fb0b3bb7c..3ce451e19 100644 --- a/windows/tauri/src/features/spring/utils/spring-navigation.test.ts +++ b/windows/tauri/src/features/spring/utils/spring-navigation.test.ts @@ -1,7 +1,16 @@ import { describe, expect, test } from "bun:test"; import type { SpringIndex } from "../types/spring.types"; -import { collectSpringIndexPaths, isSpringConfigurationPath, isSpringIndexPath } from "./spring-index-paths"; -import { resolveSpringDefinitions, resolveSpringReferences } from "./spring-navigation"; +import { + collectSpringIndexPaths, + isSpringConfigurationPath, + isSpringIndexPath, + shouldScheduleSpringReloadForExternalChange, +} from "./spring-index-paths"; +import { + resolveSpringDefinitions, + resolveSpringEndpointLocation, + resolveSpringReferences, +} from "./spring-navigation"; const ROOT = "C:/work/demo"; @@ -55,6 +64,7 @@ const index: SpringIndex = { beanIds: ["userService"], }, ], + endpoints: [], }; describe("Spring index path filters", () => { @@ -139,4 +149,67 @@ describe("Spring reference navigation", () => { "C:/work/demo/src/main/java/com/demo/ApiController.java", ); }); + + test("schedules a reload for full rescans and matching Spring files", () => { + expect(shouldScheduleSpringReloadForExternalChange("rescan", ROOT)).toBe(true); + expect(shouldScheduleSpringReloadForExternalChange("opened", "C:/work/App.java")).toBe(true); + expect(shouldScheduleSpringReloadForExternalChange("deleted", "C:/work/OldController.java")).toBe( + true, + ); + expect( + shouldScheduleSpringReloadForExternalChange( + "reloaded", + "C:/work/src/main/resources/application.yml", + ), + ).toBe(true); + // Structural events do not tell us whether a path is a directory. Refresh + // conservatively so renaming a package cannot leave controller paths stale. + expect(shouldScheduleSpringReloadForExternalChange("opened", "C:/work/README.md")).toBe(true); + expect(shouldScheduleSpringReloadForExternalChange("deleted", "C:/work/src/controllers")).toBe(true); + expect(shouldScheduleSpringReloadForExternalChange("opened", "C:/work/src/renamed")).toBe(true); + expect(shouldScheduleSpringReloadForExternalChange("reloaded", "C:/work/README.md")).toBe(false); + }); +}); + +describe("Spring endpoint navigation", () => { + test("resolves a local drive endpoint to a zero-based editor location", () => { + expect( + resolveSpringEndpointLocation( + { + id: "endpoint", + httpMethods: ["GET"], + route: "/api/users", + controller: "UserController", + method: "listUsers", + path: "src/main/java/com/demo/UserController.java", + line: 12, + column: 3, + }, + "C:/work/demo", + ), + ).toEqual({ + filePath: "C:/work/demo/src/main/java/com/demo/UserController.java", + line: 11, + column: 2, + symbol: "listUsers", + }); + }); + + test("resolves a UNC endpoint without rewriting it to a drive path", () => { + expect( + resolveSpringEndpointLocation( + { + id: "endpoint", + httpMethods: ["POST"], + route: "/api/orders", + controller: "OrderController", + method: "createOrder", + path: "src/main/java/com/demo/OrderController.java", + line: 20, + column: 5, + }, + "\\\\server\\share\\demo", + )?.filePath, + ).toBe("//server/share/demo/src/main/java/com/demo/OrderController.java"); + }); }); diff --git a/windows/tauri/src/features/spring/utils/spring-navigation.ts b/windows/tauri/src/features/spring/utils/spring-navigation.ts index 5801189c8..2f867e969 100644 --- a/windows/tauri/src/features/spring/utils/spring-navigation.ts +++ b/windows/tauri/src/features/spring/utils/spring-navigation.ts @@ -1,6 +1,7 @@ import { joinPath, normalizePath } from "@/utils/path-helpers"; import type { SpringIndex, + SpringEndpoint, SpringNavigationLocation, } from "../types/spring.types"; import { workspaceRelativeSpringPath } from "./spring-index-paths"; @@ -181,3 +182,10 @@ export function resolveSpringReferences( }), ]); } + +export function resolveSpringEndpointLocation( + endpoint: SpringEndpoint, + root: string, +): SpringNavigationLocation | null { + return toEditorLocation(root, endpoint.path, endpoint.line, endpoint.column, endpoint.method); +} diff --git a/windows/tauri/src/features/spring/utils/spring-root.test.ts b/windows/tauri/src/features/spring/utils/spring-root.test.ts new file mode 100644 index 000000000..27293b7dc --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-root.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { isSameSpringRoot, isSupportedSpringRoot, normalizeSpringRootIdentity } from "./spring-root"; + +describe("isSupportedSpringRoot", () => { + test("accepts local drive and UNC roots", () => { + expect(isSupportedSpringRoot("C:\\work\\demo")).toBe(true); + expect(isSupportedSpringRoot("C:/work/demo")).toBe(true); + expect(isSupportedSpringRoot("\\\\server\\share\\demo")).toBe(true); + expect(isSupportedSpringRoot("//server/share/demo")).toBe(true); + expect(isSupportedSpringRoot("\\\\?\\C:\\work\\demo")).toBe(true); + }); + + test("rejects WSL, remote, and relative roots", () => { + expect(isSupportedSpringRoot("wsl://Ubuntu/home/demo")).toBe(false); + expect(isSupportedSpringRoot("remote://ssh-remote/demo")).toBe(false); + expect(isSupportedSpringRoot("src/main/java")).toBe(false); + expect(isSupportedSpringRoot(null)).toBe(false); + }); +}); + +describe("normalizeSpringRootIdentity", () => { + test("normalizes separators, trailing separators, and drive case", () => { + expect(normalizeSpringRootIdentity("C:\\work\\demo")).toBe("c:/work/demo"); + expect(normalizeSpringRootIdentity("C:/WORK/DEMO/")).toBe("c:/work/demo"); + expect(normalizeSpringRootIdentity("\\\\?\\C:\\Work\\Demo")).toBe("c:/work/demo"); + }); + + test("preserves UNC path case while normalizing separators", () => { + expect(normalizeSpringRootIdentity("\\\\Server\\Share\\Demo\\")).toBe( + "//Server/Share/Demo", + ); + }); +}); + +describe("isSameSpringRoot", () => { + test("treats textual variants of the same local root as the same root", () => { + expect(isSameSpringRoot("C:\\work\\demo", "c:/work/demo/")).toBe(true); + expect(isSameSpringRoot("\\\\?\\C:\\work\\demo", "C:/work/demo")).toBe(true); + }); + + test("rejects a different logical root", () => { + expect(isSameSpringRoot("C:\\work\\demo", "D:\\work\\demo")).toBe(false); + expect(isSameSpringRoot("wsl://Ubuntu/home/demo", "wsl://Ubuntu/home/demo")).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/spring/utils/spring-root.ts b/windows/tauri/src/features/spring/utils/spring-root.ts new file mode 100644 index 000000000..a40fe9dcc --- /dev/null +++ b/windows/tauri/src/features/spring/utils/spring-root.ts @@ -0,0 +1,26 @@ +import { normalizePath, stripTrailingPathSeparators } from "@/utils/path-helpers"; + +const SCHEME_PREFIX = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//; +const WINDOWS_DRIVE_ABSOLUTE = /^[A-Za-z]:\//; +const WINDOWS_UNC_ABSOLUTE = /^\/\/[^/]/; + +export function isSupportedSpringRoot(root: string | null | undefined): root is string { + if (!root || SCHEME_PREFIX.test(root)) return false; + const normalized = normalizePath(root); + return WINDOWS_DRIVE_ABSOLUTE.test(normalized) || WINDOWS_UNC_ABSOLUTE.test(normalized); +} + +export function normalizeSpringRootIdentity(root: string | null | undefined): string | null { + if (!isSupportedSpringRoot(root)) return null; + const normalized = stripTrailingPathSeparators(normalizePath(root)); + return WINDOWS_DRIVE_ABSOLUTE.test(normalized) ? normalized.toLowerCase() : normalized; +} + +export function isSameSpringRoot( + left: string | null | undefined, + right: string | null | undefined, +): boolean { + const leftIdentity = normalizeSpringRootIdentity(left); + const rightIdentity = normalizeSpringRootIdentity(right); + return leftIdentity !== null && leftIdentity === rightIdentity; +} diff --git a/windows/tauri/src/features/window/services/project-window-router.test.ts b/windows/tauri/src/features/window/services/project-window-router.test.ts new file mode 100644 index 000000000..17e46966c --- /dev/null +++ b/windows/tauri/src/features/window/services/project-window-router.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from "bun:test"; +import { createProjectWindowRouter, type ProjectWindowOwner } from "./project-window-router"; + +function harness() { + const tabs = [ + { id: "project-a", path: "C:/projects/a" }, + { id: "project-b", path: "C:/projects/b" }, + ]; + const claims: string[] = []; + const switches: string[] = []; + const releases: string[] = []; + const errors: unknown[] = []; + let listener: ((id: string) => void) | undefined; + let stops = 0; + let owner: ProjectWindowOwner = { label: "main", workspaceId: "project-a" }; + const router = createProjectWindowRouter({ + label: "main", + tabs: () => tabs, + claim: async (tab, activate) => { + claims.push(`${tab.id}:${activate}`); + return activate ? owner : { label: "main", workspaceId: tab.id }; + }, + release: async (id) => { + releases.push(id); + }, + listen: async (activate) => { + listener = activate; + return () => { + stops++; + listener = undefined; + }; + }, + switchTo: async (id) => { + switches.push(id); + return true; + }, + reportError: (error) => { + errors.push(error); + }, + }); + return { + router, + tabs, + claims, + switches, + releases, + errors, + activate: (id: string) => listener?.(id), + setOwner: (value: ProjectWindowOwner) => { + owner = value; + }, + stops: () => stops, + }; +} + +test("registers inactive restored tabs before opening and initializes only once", async () => { + const h = harness(); + try { + await Promise.all([h.router.initialize(), h.router.initialize()]); + expect(await h.router.claim(h.tabs[0]!)).toBe(false); + expect(h.claims).toEqual(["project-a:false", "project-b:false", "project-a:true"]); + } finally { + h.router.dispose(); + } + expect(h.stops()).toBe(1); +}); + +test("reuses another window without initializing or switching the caller", async () => { + const h = harness(); + try { + h.setOwner({ label: "workspace-1", workspaceId: "project-a" }); + expect(await h.router.claim(h.tabs[0]!)).toBe(true); + expect(h.switches).toEqual([]); + } finally { + h.router.dispose(); + } +}); + +test("activates the original same-window tab for a directory alias", async () => { + const h = harness(); + try { + expect(await h.router.claim({ id: "alias", path: "C:/links/a" })).toBe(true); + expect(h.switches).toEqual(["project-a"]); + } finally { + h.router.dispose(); + } +}); + +test("does not send remote or WSL project identities to the local filesystem", async () => { + const h = harness(); + try { + expect(await h.router.claim({ id: "remote", path: "remote://server/project" })).toBe(false); + expect(await h.router.claim({ id: "wsl", path: "wsl://Ubuntu/project" })).toBe(false); + expect(h.claims).toEqual([]); + } finally { + h.router.dispose(); + } +}); + +test("releases ownership by workspace ID even if its directory was removed", async () => { + const h = harness(); + try { + await h.router.initialize(); + h.tabs.length = 0; + await h.router.release("project-a"); + expect(h.releases).toEqual(["project-a"]); + } finally { + h.router.dispose(); + } +}); + +test("routes activation events to background tabs and ignores closed tabs", async () => { + const h = harness(); + try { + await h.router.initialize(); + h.activate("project-b"); + // The activation queue crosses one promise boundary before switchTo. + await Promise.resolve(); + expect(h.switches).toEqual(["project-b"]); + h.activate("closed-tab"); + } finally { + h.router.dispose(); + } + expect(h.errors).toEqual([]); +}); + +test("cleans up a listener whose registration completes after disposal", async () => { + const h = harness(); + const ready = h.router.initialize(); + h.router.dispose(); + await ready; + expect(h.stops()).toBe(1); + expect(h.claims).toEqual([]); +}); diff --git a/windows/tauri/src/features/window/services/project-window-router.ts b/windows/tauri/src/features/window/services/project-window-router.ts new file mode 100644 index 000000000..2d8a5319b --- /dev/null +++ b/windows/tauri/src/features/window/services/project-window-router.ts @@ -0,0 +1,92 @@ +export interface ProjectWindowOwner { + label: string; + workspaceId: string | null; +} + +interface ProjectTabIdentity { + id: string; + path: string; +} + +export const isLocalProjectPath = (path: string) => !path.includes("://"); + +interface ProjectWindowRouterDependencies { + label: string; + tabs: () => ProjectTabIdentity[]; + claim: (tab: ProjectTabIdentity, activate: boolean) => Promise; + release: (workspaceId: string) => Promise; + listen: (activate: (workspaceId: string) => void) => Promise<() => void>; + switchTo: (workspaceId: string) => Promise; + reportError: (error: unknown) => void; +} + +// One router lives for the lifetime of a WebView, not for a React render. +export function createProjectWindowRouter(deps: ProjectWindowRouterDependencies) { + let ready: Promise | undefined; + let unlisten: (() => void) | undefined; + let disposed = false; + let activation = Promise.resolve(); + + const initialize = () => { + ready ??= (async () => { + const stop = await deps.listen((workspaceId) => { + activation = activation + .then(async () => { + if (!disposed && deps.tabs().some((tab) => tab.id === workspaceId)) { + if (!(await deps.switchTo(workspaceId))) { + throw new Error("Could not activate the existing project workspace"); + } + } + }) + .catch(deps.reportError); + }); + if (disposed) { + stop(); + return; + } + unlisten = stop; + // Restore ownership of inactive tabs too, before accepting new opens. + for (const tab of deps.tabs()) { + if (disposed) return; + if (!isLocalProjectPath(tab.path)) continue; + try { + await deps.claim(tab, false); + } catch (error) { + // Missing persisted directories must not prevent the app from opening. + deps.reportError(error); + } + } + })().catch((error) => { + ready = undefined; + throw error; + }); + return ready; + }; + + return { + initialize, + async claim(tab: ProjectTabIdentity) { + if (!isLocalProjectPath(tab.path)) return false; + await initialize(); + const owner = await deps.claim(tab, true); + if (owner.label !== deps.label) return true; + // Native identity can match a different spelling (case, link, separators). + if (owner.workspaceId && owner.workspaceId !== tab.id) { + if (!(await deps.switchTo(owner.workspaceId))) { + throw new Error("Could not activate the existing project workspace"); + } + return true; + } + return false; + }, + async release(workspaceId: string) { + await initialize(); + await deps.release(workspaceId); + }, + dispose() { + disposed = true; + unlisten?.(); + unlisten = undefined; + }, + }; +} diff --git a/windows/tauri/src/features/window/services/project-window-routing.ts b/windows/tauri/src/features/window/services/project-window-routing.ts new file mode 100644 index 000000000..032905ee4 --- /dev/null +++ b/windows/tauri/src/features/window/services/project-window-routing.ts @@ -0,0 +1,28 @@ +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { invoke } from "@/platform/tauri-core"; +import { useWorkspaceTabsStore } from "@/features/window/stores/workspace-tabs.store"; +import { createProjectWindowRouter, type ProjectWindowOwner } from "./project-window-router"; + +const currentWindow = getCurrentWebviewWindow(); +export const projectWindowRouting = createProjectWindowRouter({ + label: currentWindow.label, + tabs: () => useWorkspaceTabsStore.getState().projectTabs, + claim: (tab, activate) => + invoke("claim_project_window", { + path: tab.path, + workspaceId: tab.id, + activate, + }), + release: (workspaceId) => invoke("release_project_window", { workspaceId }), + listen: (activate) => + currentWindow.listen("activate_project_workspace", (event) => { + activate(event.payload); + }), + switchTo: async (workspaceId) => { + const { useFileSystemStore } = await import("@/features/file-system/stores/file-system.store"); + return useFileSystemStore.getState().switchToProject(workspaceId); + }, + reportError: (error) => console.warn("[project-window] routing failed:", error), +}); + +window.addEventListener("unload", () => projectWindowRouting.dispose(), { once: true }); diff --git a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts index dd9e4ef9d..b08279944 100644 --- a/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts +++ b/windows/tauri/src/features/window/stores/ui-state/types/ui-state.types.ts @@ -12,6 +12,7 @@ export type SettingsTab = | "logs" | "advanced" | "terminal" + | "maven" | "file-explorer"; export type BottomPaneTab = @@ -23,7 +24,8 @@ export type BottomPaneTab = | "run" // Maven task output is independent from the right-side Maven navigation tool window. | "maven" - | "gitLog"; + | "gitLog" + | "springEndpoints"; export interface QuickEditSelection { text: string; diff --git a/windows/tauri/src/features/window/utils/create-app-window.ts b/windows/tauri/src/features/window/utils/create-app-window.ts index 6e3a40b17..023c34f3d 100644 --- a/windows/tauri/src/features/window/utils/create-app-window.ts +++ b/windows/tauri/src/features/window/utils/create-app-window.ts @@ -19,6 +19,8 @@ export async function createAppWindow(request?: WindowOpenRequest | null) { traceWindowOpen("createAppWindow:invoke:start", { requestKind }); try { + const { projectWindowRouting } = await import("../services/project-window-routing"); + await projectWindowRouting.initialize(); const label = await invoke("create_app_window", { request: request ?? null, } satisfies CreateAppWindowPayload); diff --git a/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts b/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts new file mode 100644 index 000000000..84d3c1883 --- /dev/null +++ b/windows/tauri/src/features/workspace/services/workspace-lifecycle.test.ts @@ -0,0 +1,150 @@ +import { afterEach, beforeEach, expect, mock, test } from "bun:test"; + +const tabs: { id: string; path: string; name: string }[] = []; +const workspaces = new Map(); +let active = "welcome"; +let redirected = false; +const released: string[] = []; +const actions = { + setActiveProjectTab: (id: string) => { + active = id; + }, + addProjectTab: (path: string, name: string) => { + const id = createProjectTabId(path); + if (!tabs.some((tab) => tab.id === id)) tabs.push({ id, path, name }); + }, + removeProjectTab: (id: string) => { + const index = tabs.findIndex((tab) => tab.id === id); + if (index >= 0) tabs.splice(index, 1); + }, + getActiveProjectTab: () => tabs.find((tab) => tab.id === active), +}; +mock.module("@/extensions/run/extension-process-owner", () => ({ + extensionProcessOwner: { stop: async () => {} }, +})); +mock.module("@/features/settings/stores/settings.store", () => ({ + useSettingsStore: { getState: () => ({ settings: { theme: "dark" } }) }, +})); +mock.module("@/features/window/stores/workspace-tabs.store", () => ({ + useWorkspaceTabsStore: { getState: () => ({ projectTabs: tabs, actions }) }, +})); +mock.module("@/features/workspace/runtime/workspace-runtime-registry", () => ({ + workspaceRuntimeRegistry: { + getActiveWorkspaceId: () => active, + hasWorkspace: (id: string) => workspaces.has(id), + isWorkspaceReady: (id: string) => workspaces.get(id)?.status === "ready", + activateWorkspace: (descriptor: { id: string }, status: string) => { + active = descriptor.id; + workspaces.set(descriptor.id, { status }); + }, + ensureWorkspace: (descriptor: { id: string }, status: string) => { + workspaces.set(descriptor.id, { status }); + }, + updateWorkspaceStatus: (id: string, status: string) => { + workspaces.set(id, { status }); + }, + removeWorkspace: (id: string) => { + workspaces.delete(id); + }, + getWorkspace: (id: string) => workspaces.get(id), + }, +})); +mock.module("@/features/window/services/project-window-routing", () => ({ + projectWindowRouting: { + claim: async () => redirected, + release: async (id: string) => { + released.push(id); + }, + }, +})); +const { createProjectTabId } = await import("@/features/window/utils/project-tab-path"); +const { openWorkspaceRuntime, closeWorkspaceRuntime } = await import("./workspace-lifecycle"); + +beforeEach(() => { + tabs.length = 0; + workspaces.clear(); + active = "welcome"; + redirected = false; + released.length = 0; +}); +afterEach(() => { + mock.restore(); +}); + +const descriptor = { path: "C:/projects/example", name: "example" }; + +test("a project owned by another window does not mutate the current workspace", async () => { + redirected = true; + let initialized = false; + let persisted = false; + expect( + await openWorkspaceRuntime({ + descriptor, + initialize: async () => { + initialized = true; + return true; + }, + persistCurrent: () => { + persisted = true; + }, + }), + ).toBe(true); + expect(initialized).toBe(false); + expect(persisted).toBe(false); + expect(tabs).toEqual([]); + expect(active).toBe("welcome"); +}); + +test("concurrent opens share initialization and release ownership on failure before retry", async () => { + let complete!: (value: boolean) => void; + const gate = new Promise((resolve) => { + complete = resolve; + }); + let initializations = 0; + const first = openWorkspaceRuntime({ + descriptor, + initialize: () => { + initializations++; + return gate; + }, + }); + try { + const second = openWorkspaceRuntime({ + descriptor, + initialize: async () => { + initializations++; + return true; + }, + }); + expect(second).toBe(first); + complete(false); + expect(await first).toBe(false); + expect(await second).toBe(false); + expect(initializations).toBe(1); + expect(tabs).toEqual([]); + expect(released).toEqual([createProjectTabId(descriptor.path)]); + expect(await openWorkspaceRuntime({ descriptor, initialize: async () => true })).toBe(true); + expect(tabs).toHaveLength(1); + } finally { + complete(false); + await first; + } +}); + +test("closing a project releases its ownership after disposal", async () => { + await openWorkspaceRuntime({ descriptor, initialize: async () => true }); + const id = createProjectTabId(descriptor.path); + let disposed = false; + expect( + await closeWorkspaceRuntime(id, { + dispose: async () => { + expect(released).toEqual([]); + disposed = true; + }, + switchTo: async () => true, + }), + ).toBe(true); + expect(disposed).toBe(true); + expect(released).toEqual([id]); + expect(tabs).toEqual([]); +}); diff --git a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts index 0ddae7d88..317b23f08 100644 --- a/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts +++ b/windows/tauri/src/features/workspace/services/workspace-lifecycle.ts @@ -113,13 +113,33 @@ const restorePreviousWorkspace = (workspaceId: string | undefined) => { ); }; -export async function openWorkspaceRuntime({ +const pendingWorkspaceOpens = new Map>(); + +export function openWorkspaceRuntime(options: OpenWorkspaceRuntimeOptions) { + const workspaceId = createProjectTabId(options.descriptor.path); + const pending = pendingWorkspaceOpens.get(workspaceId); + if (pending) return pending; + + // A focus event may arrive while this workspace is still initializing. + // Reuse that initialization instead of starting its services a second time. + const opening = openWorkspaceRuntimeOnce(options).finally(() => { + pendingWorkspaceOpens.delete(workspaceId); + }); + pendingWorkspaceOpens.set(workspaceId, opening); + return opening; +} + +async function openWorkspaceRuntimeOnce({ descriptor, initialize, persistCurrent, resume, }: OpenWorkspaceRuntimeOptions) { const workspaceId = createProjectTabId(descriptor.path); + const { projectWindowRouting } = await import("@/features/window/services/project-window-routing"); + if (await projectWindowRouting.claim({ id: workspaceId, path: descriptor.path })) { + return true; + } const previousWorkspaceId = workspaceRuntimeRegistry.getActiveWorkspaceId(); const wasKnown = workspaceRuntimeRegistry.hasWorkspace(workspaceId); const wasReady = workspaceRuntimeRegistry.isWorkspaceReady(workspaceId); @@ -161,6 +181,7 @@ export async function openWorkspaceRuntime({ if (!wasKnown) { useWorkspaceTabsStore.getState().actions.removeProjectTab(workspaceId); workspaceRuntimeRegistry.removeWorkspace(workspaceId); + await projectWindowRouting.release(workspaceId); } if (shouldRestorePrevious) { restorePreviousWorkspace(previousWorkspaceId); @@ -242,6 +263,8 @@ export async function closeWorkspaceRuntime( workspaceTabs.actions.removeProjectTab(workspaceId); workspaceRuntimeRegistry.removeWorkspace(workspaceId); + const { projectWindowRouting } = await import("@/features/window/services/project-window-routing"); + await projectWindowRouting.release(workspaceId); if (!wasActive) { return true; diff --git a/windows/tauri/src/i18n/locale.ts b/windows/tauri/src/i18n/locale.ts index 86b2eb173..111652f0b 100644 --- a/windows/tauri/src/i18n/locale.ts +++ b/windows/tauri/src/i18n/locale.ts @@ -1471,6 +1471,22 @@ const catalogs = { "workbench.maven": "Maven", "workbench.terminal": "Terminal", "workbench.diagnostics": "Diagnostics", + "workbench.springEndpoints": "Spring Endpoints", + "springEndpoints.filterPlaceholder": "Filter route, controller, or method", + "springEndpoints.routeCount": "{count} routes", + "springEndpoints.routeCountOne": "{count} route", + "springEndpoints.loading": "Indexing Spring project...", + "springEndpoints.refreshing": "Updating Spring endpoints...", + "springEndpoints.empty": "No Spring MVC endpoints found", + "springEndpoints.noMatch": "No endpoints match the current filter", + "springEndpoints.stale": "Showing the last successful Spring index. Refresh failed.", + "springEndpoints.error.unsupportedRoot": "Spring endpoint indexing is unavailable for this workspace.", + "springEndpoints.error.rootUnavailable": "The Spring workspace root is unavailable.", + "springEndpoints.error.permissionDenied": "Spring endpoint indexing does not have permission to read this workspace.", + "springEndpoints.error.indexTimeout": "Spring endpoint indexing timed out.", + "springEndpoints.error.indexFailed": "Spring endpoint indexing failed.", + "keybindings.commands.workbench.toggleSpringEndpoints.title": "Toggle Spring Endpoints", + "keybindings.commands.workbench.toggleSpringEndpoints.description": "Show or hide indexed Spring MVC endpoints", "preparation.starting": "Starting Java service", "preparation.importing": "Importing Java project and dependencies", "preparation.configuring": "Synchronizing Java project configuration", @@ -1627,6 +1643,10 @@ const catalogs = { "maven.scopeUpdatedFrom": "scope updated from {value}", "maven.scopeNotUpdatedTo": "scope not updated to {value}", "maven.settings": "Maven Settings", + "maven.settingsDescription": "Leave a field empty to let Lithe detect it automatically.", + "maven.detectedValue": "Detected: {value}", + "maven.detectedMissing": "Not detected on this machine", + "maven.detecting": "Detecting…", "maven.automatic": "Automatic", "maven.localRepository": "Local repository", "maven.mavenExecutable": "Maven home or executable", @@ -1811,6 +1831,7 @@ const catalogs = { "settings.tabs.keyboard": "Keybindings", "settings.tabs.advanced": "Advanced", "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", "settings.tabs.aiCommit": "AI & Commit", "settings.tabs.logs": "Logs", "settings.tabs.updates": "Updates", @@ -5917,6 +5938,22 @@ const catalogs = { "workbench.maven": "Maven", "workbench.terminal": "终端", "workbench.diagnostics": "诊断", + "workbench.springEndpoints": "Spring 接口", + "springEndpoints.filterPlaceholder": "筛选路由、控制器或方法", + "springEndpoints.routeCount": "{count} 个路由", + "springEndpoints.routeCountOne": "{count} 个路由", + "springEndpoints.loading": "正在索引 Spring 项目...", + "springEndpoints.refreshing": "正在更新 Spring 接口...", + "springEndpoints.empty": "未找到 Spring MVC 接口", + "springEndpoints.noMatch": "没有匹配当前筛选条件的接口", + "springEndpoints.stale": "正在显示上次成功索引的结果,刷新失败。", + "springEndpoints.error.unsupportedRoot": "当前工作区不支持 Spring 接口索引。", + "springEndpoints.error.rootUnavailable": "Spring 工作区根目录不可用。", + "springEndpoints.error.permissionDenied": "没有读取当前工作区的权限,无法索引 Spring 接口。", + "springEndpoints.error.indexTimeout": "Spring 接口索引超时。", + "springEndpoints.error.indexFailed": "Spring 接口索引失败。", + "keybindings.commands.workbench.toggleSpringEndpoints.title": "切换 Spring 接口", + "keybindings.commands.workbench.toggleSpringEndpoints.description": "显示或隐藏已索引的 Spring MVC 接口", "preparation.starting": "正在启动 Java 服务", "preparation.importing": "正在导入 Java 项目与依赖", "preparation.configuring": "正在同步 Java 项目配置", @@ -6066,6 +6103,10 @@ const catalogs = { "maven.scopeUpdatedFrom": "scope 由 {value} 提升", "maven.scopeNotUpdatedTo": "未提升为 {value} scope", "maven.settings": "Maven 设置", + "maven.settingsDescription": "留空表示由 Lithe 自动检测。", + "maven.detectedValue": "检测到:{value}", + "maven.detectedMissing": "本机未检测到", + "maven.detecting": "正在检测…", "maven.automatic": "自动检测", "maven.localRepository": "本地仓库", "maven.mavenExecutable": "Maven 主目录 / 可执行文件", @@ -6243,6 +6284,7 @@ const catalogs = { "settings.tabs.keyboard": "快捷键", "settings.tabs.advanced": "高级", "settings.tabs.lsp": "LSP", + "settings.tabs.maven": "Maven", "settings.tabs.aiCommit": "AI 与提交", "settings.tabs.logs": "日志", "settings.tabs.updates": "更新", diff --git a/windows/tauri/src/platform/lsp-core-adapter.test.ts b/windows/tauri/src/platform/lsp-core-adapter.test.ts index 5d9cb2798..9db691cf5 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.test.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.test.ts @@ -50,6 +50,10 @@ let semanticRequestPending = false; let semanticOperationId = ""; let semanticRequestResult: unknown = { locations: [] }; let semanticRequestResults: unknown[] = []; +let semanticResultGate: Promise | undefined; +let releaseSemanticResult: (() => void) | undefined; +let onSemanticRequest: (() => void) | undefined; +let semanticRefreshOnReady = false; let releaseInitialization: (() => void) | undefined; /** A queued semantic outcome that Core reports as a structured request error. */ @@ -260,11 +264,15 @@ const executeCore = mock( return { id: request.id, ok: true as const, - data: { events: readyEvents(sessionId) }, + data: { events: [ + ...readyEvents(sessionId), + ...(semanticRefreshOnReady ? [{ type: "semanticTokensRefresh", sessionId }] : []), + ] }, }; } if (semanticRequestPending) { semanticRequestPending = false; + await semanticResultGate; const outcome = semanticRequestResults.length > 0 ? semanticRequestResults.shift() @@ -367,6 +375,7 @@ const executeCore = mock( if (scenario === "semantic-request") { semanticRequestPending = true; semanticOperationId = operationId; + onSemanticRequest?.(); } else { virtualDocumentPending = true; } @@ -434,6 +443,10 @@ describe("Rust Core LSP adapter failures", () => { semanticOperationId = ""; semanticRequestResult = { locations: [] }; semanticRequestResults = []; + semanticResultGate = undefined; + releaseSemanticResult = undefined; + onSemanticRequest = undefined; + semanticRefreshOnReady = false; releaseInitialization = undefined; releaseRuntimeReady = undefined; emit.mockClear(); @@ -442,6 +455,7 @@ describe("Rust Core LSP adapter failures", () => { }); afterEach(async () => { + releaseSemanticResult?.(); releaseInitialization?.(); releaseRuntimeReady?.(); await invokeLsp("lsp_stop", { workspacePath: "C:/work/project" }); @@ -1397,6 +1411,47 @@ describe("Rust Core LSP adapter failures", () => { expect(commands.filter((command) => command === "lsp.stopServer")).toHaveLength(3); }); + test("routes semantic tokens through Core and preserves its negotiated legend and null results", async () => { + scenario = "semantic-request"; + const fixture = JSON.parse(readFileSync(new URL("../../../../shared/fixtures/lsp/semantic-tokens-v1.json", import.meta.url), "utf8")); + semanticRequestResult = fixture.expectedResult; + const filePath = "C:/work/Main.java"; + await invokeLsp("lsp_start_for_file", { workspacePath: "C:/work", filePath, languageId: "java", serverPath: "java-lsp" }); + expect(await invokeLsp("lsp_get_semantic_tokens", { filePath })).toEqual(fixture.expectedResult); + expect(requestPayload).toEqual({ sessionId: "java-session", operation: "semanticTokens", uri: "file:///C:/work/Main.java" }); + semanticRequestResult = null; + expect(await invokeLsp("lsp_get_semantic_tokens", { filePath })).toBeNull(); + semanticRequestResult = { coreError: { code: "cancelled", message: "Document changed" } }; + await expect(invokeLsp("lsp_get_semantic_tokens", { filePath })).rejects.toMatchObject({ code: "cancelled" }); + }); + + test("forwards Core semantic token refresh notifications", async () => { + scenario = "semantic-request"; + semanticRefreshOnReady = true; + await invokeLsp("lsp_start_for_file", { workspacePath: "C:/work", filePath: "C:/work/Main.java", languageId: "java", serverPath: "java-lsp" }); + expect(emit).toHaveBeenCalledWith("lsp://semantic-tokens-refresh", { sessionId: "java-session", workspacePath: "C:/work" }); + }); + + test("discards semantic colors from a replaced file attachment", async () => { + scenario = "semantic-request"; + semanticRequestResult = { tokenTypes: ["class"], tokenModifiers: [], tokens: [] }; + const filePath = "C:/work/Main.java"; + const start = { workspacePath: "C:/work", filePath, languageId: "java", serverPath: "java-lsp" }; + await invokeLsp("lsp_start_for_file", { ...start, attachmentId: "old" }); + semanticResultGate = new Promise((resolve) => { releaseSemanticResult = resolve; }); + const requested = new Promise((resolve) => { onSemanticRequest = resolve; }); + const result = invokeLsp("lsp_get_semantic_tokens", { filePath }); + try { + await requested; + await invokeLsp("lsp_start_for_file", { ...start, attachmentId: "replacement" }); + releaseSemanticResult!(); + expect(await result).toBeNull(); + } finally { + releaseSemanticResult!(); + await result.catch(() => undefined); + } + }); + test("returns a structured capability error for explicitly unavailable commands", async () => { await expect( invokeLsp("lsp_prepare_rename", { @@ -1423,6 +1478,7 @@ describe("Rust Core LSP adapter failures", () => { lsp_format_document: "formatting", lsp_get_code_actions: "codeActions", lsp_get_inlay_hints: "inlayHints", + lsp_get_semantic_tokens: "semanticTokens", lsp_get_code_lens: "codeLens", lsp_get_virtual_document: "virtualDocument", }); diff --git a/windows/tauri/src/platform/lsp-core-adapter.ts b/windows/tauri/src/platform/lsp-core-adapter.ts index e475d7828..1b63e1386 100644 --- a/windows/tauri/src/platform/lsp-core-adapter.ts +++ b/windows/tauri/src/platform/lsp-core-adapter.ts @@ -463,6 +463,12 @@ async function dispatchSessionEvent(session: Session, event: RuntimeEvent): Prom } persistSessions(); } + if (event.type === "semanticTokensRefresh") { + await emit("lsp://semantic-tokens-refresh", { + sessionId: session.id, + workspacePath: session.workspacePath, + }); + } if (event.type === "featuresChanged") { session.featureState = { phase: "known", features: new Set(event.capabilities ?? []) }; persistSessions(); @@ -1238,12 +1244,12 @@ export const LSP_OPERATION_BY_COMMAND = { lsp_format_document: "formatting", lsp_get_code_actions: "codeActions", lsp_get_inlay_hints: "inlayHints", + lsp_get_semantic_tokens: "semanticTokens", lsp_get_code_lens: "codeLens", lsp_get_virtual_document: "virtualDocument", } as const; export const LSP_EXPLICITLY_UNAVAILABLE_COMMANDS = [ - "lsp_get_semantic_tokens", "lsp_get_document_symbols", "lsp_get_workspace_symbols", "lsp_get_signature_help", @@ -1352,8 +1358,12 @@ function unwrapResult(command: string, result: any): unknown { } async function semanticRequest(command: string, args: JsonRecord): Promise { + const key = fileKey(args.sessionFilePath ?? args.filePath); + const attachment = fileSessions.get(key); const session = sessionForFile(args.sessionFilePath ?? args.filePath); const result = await requestOperation(session, semanticPayload(command, args, session)); + // A stopped/replaced attachment must not repaint the new session's document. + if (command === "lsp_get_semantic_tokens" && fileSessions.get(key) !== attachment) return null; return unwrapResult(command, result); } diff --git a/windows/tauri/src/platform/tauri-core.ts b/windows/tauri/src/platform/tauri-core.ts index 4ba3f0d90..05e92ac33 100644 --- a/windows/tauri/src/platform/tauri-core.ts +++ b/windows/tauri/src/platform/tauri-core.ts @@ -31,6 +31,9 @@ const nativeCommands = new Set([ "save_document_file", "set_document_watches", "create_app_window", + "claim_project_window", + "release_project_window", + "release_pending_project_window", "create_terminal", "debug_send_request", "debug_start_session",