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 index de6293e2e..62b2f7df5 100644 --- 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 @@ -69,6 +69,19 @@ Agent 对话默认关闭,打开某个项目的 Agent 面板时才启动本机 不要这样做:在 Windows React 层重新实现 JSON-RPC 协议;在打开 IDE 时就启动 Agent;取消尚未确认就解锁发送;把取消超时伪装成成功而不提示用户重连;把 API Key 通过环境变量或命令行传给 Agent;替用户下载一份他本机已有的 Agent CLI。 +### 思考、计划、斜杠命令与模式通知 + +共享 Host 一直把 ACP 会话更新(`SessionUpdate`)原样转发,以前 Swift 只消费消息、工具、标题、配置和用量,其余直接丢弃。参考 CC GUI 的可折叠思考块、计划面板与 `/` 命令补全后,macOS 现在消费下面四类标准更新;Host、C ABI 和 fixture 形状都不变,只在 fixture 中补样例并由 Rust 测试确认它们能被官方 SDK 原样往返。 + +- **思考(`agent_thought_chunk`)**:存为独立的 `thought` 角色消息,不并进 Agent 回复。流式缓冲按角色记录,角色切换先刷新上一段,所以“思考 → 回复 → 工具 → 思考”保持原顺序。只有正在流式输出的最后一段思考默认展开,后续回复或工具到达后自动收起,用户手动展开的状态保留;搜索时临时展开匹配的思考,优先于已有的手动折叠选择;搜索期间不允许再次收起命中文字,清空搜索后恢复手动选择,不把搜索结果写回折叠偏好。手动偏好由 `AgentTranscriptView` 在视图局部内存中按会话 ID 与消息 ID 保存,再以绑定传给思考行;搜索先过滤消息,会移除并重建行,因此不能让行内状态成为偏好的所有者。切换会话保留各自选择,关闭会话或移除实际消息时释放对应偏好,销毁对话视图时一并释放;不写入功能模型、共享 Host、历史文件或安装目录,不影响代码签名和 Sparkle delta。`AgentThoughtTranscriptTests` 直接挂载实际对话容器,通过原生鼠标事件和完整渲染快照覆盖搜索移除/重建、命中时临时展开和会话切换。导出 Markdown 用引用块写出思考,便于和回复区分;历史消息数仍只统计用户与 Agent 回复。活跃思考块的标题显示“思考中…”,底部等待行改为“回复中…”并保留本轮计时,避免重复提示;取消时仍显示“正在停止…”。 +- **计划(`plan`)**:ACP 规定每次都发完整计划,所以整体替换,不做合并;空列表清除计划,格式错误的更新忽略,最多保留 100 条。计划条状态只认 `pending`、`in_progress`、`completed`,未知状态的条目丢弃而不是猜测。计划栏固定在活动统计栏上方,折叠时显示进度和当前步骤。`session/load` 回放会自然恢复计划。 +- **斜杠命令(`available_commands_update`)**:按会话保存上游列表,最多读取前 200 条;同名命令只保留首条定义,保证命令行身份唯一。断连时清空每个会话的旧命令,重连加载后等待新进程重新上报,避免显示已经失效的能力。输入框以 `/` 开头且命令名中还没有空白时弹出过滤结果,前缀匹配排在包含匹配之前;选择后只把 `/name ` 填入草稿,命令文本仍作为普通 prompt 原样发送,由 Agent 自己解释。实测 codex-acp 1.13.1 把 Skill 以 `$name` 形式放进同一个命令列表,而它的命令解析会跳过 `/$name`,Skill 需要以 `$name` 提及。因此名字以 `$` 开头的命令补全为 `$name `;输入 `$` 只列 Skill,Agent 没有上报 Skill 时 `$` 仍是普通文字。Lithe 不扫描 SKILL.md 或命令目录,也不内置命令表,避免和 Agent 的真实能力不一致。方向键和 Tab 导航依赖 macOS 14 的 `onKeyPress`;macOS 13 保留鼠标选择和 Return 补全。Return、Esc、方向键与 Tab 共用视图局部的补全状态,Return 在命令名未完整时只补全、完整时发送;Esc 先关闭补全,再次 Esc 才停止回复,编辑草稿后重新显示匹配。补全高度由输入区内部的局部布局容器按剩余空间限制,先保留一行可编辑文字;最小输入区放不下一整行命令时,列表浮在输入框上方,通过锚点偏好在整个对话布局中绘制,让鼠标点击范围不被较小的输入面板限制;沿用工作台悬停提示的局部 overlay 模式,不新增全局位置状态或鼠标监听。输入区的最小高度包含上下栏、可编辑行和外边距,附带文件时再预留附件行;这个值只在附件有无改变时通过视图偏好更新,不跟随拖动逐帧发布。不要让固定高度的命令列表参与分隔条的最小尺寸计算,也不要把逐帧几何尺寸发布到会话模型。 +- **模式通知(`current_mode_update`)**:Agent 自己切换模式(例如退出计划模式)时,只更新 `category == "mode"` 且包含该值的配置选项,不向上游回发设置请求;选项中不存在的模式 ID 忽略,不显示原始 ID。 + +正确做法:新的更新类型先在 fixture 中加入样例,让 Rust 往返测试证明它是合法 ACP,再在平台功能模型中解析。不要这样做:在视图中直接解析原始更新;为了显示命令列表而自己维护一份 Claude 或 Codex 命令表。 + +这些状态只保存在会话内存中,不新增日志、下载、缓存或可复用工作树资源;安装目录和 bundle 仍只读,不影响代码签名和 Sparkle delta。 + ### 每轮耗时与上报 token 发送后用单调时钟(不受系统日期调整影响的计时源)开始测量,包含建会话、加载历史、工具执行和权限等待。界面“思考中”的秒数表示本轮已经过的时间,不声称是模型内部推理时间。只让可见等待行每秒重绘,不每秒发布整个会话。正常完成、请求失败和断连固定耗时;取消仍等上游确认才结束。每轮末尾保留统计,标签切换不会重置,失败发送和历史回放不编造记录。 @@ -145,6 +158,7 @@ npm 的进度选项只面向终端,HTTP 日志通常在请求完成后才输 ## 验证 +- `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentThoughtTranscriptTests`:在生产对话容器中通过鼠标展开/收起思考,搜索隐藏行后进入空会话使列表实际销毁,再返回并清空搜索,确认手动选择保留;同时验证命中时保持文字可见、清空后恢复折叠、会话切换独立和关闭后重新加载的默认状态。渲染只等待可观察的原生画面,每次等待最多两秒;比较同一宿主的完整画面,不依赖系统字体快照基线。窗口和功能模型在成功、失败路径都清理。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter AgentConversationSelectionTests`:原生 SwiftUI 宿主不替换根视图,直接改变模块选择,验证 Agent、模型与连接立即同步;配置确认前保留旧值、确认后刷新,来回切换保持各自配置,删除当前 Agent 时正确回退,关闭全部测试窗口和连接。另对 Claude/Codex 显式驱动最后一个标签关闭→唯一新建请求→过期响应→新会话确认→发送,确认连接不变、准备期间不闪现模型、确认后底栏恢复且消息只交给新会话。显式控制切换→连接→ready→创建确认及历史加载的事件顺序,比较原生工具栏,验证准备期间改变本地默认模型不影响显示、过期响应不能解除等待、已确认连接复用、失败和无配置能力不会一直加载。2026-10-01 修复版真实窗口已验证 Codex 初始化加载提示到 Astra/审批控件、Astra→Luna→Astra 模型切换和重新展开的勾选、模型名称搜索与空结果、Claude/Codex 来回切换保留配置,Codex 最小消息收到预期回复。关闭测试项目后确认无 Agent 子进程,990 个 bundle 文件哈希不变;Windows 对话 UI 与运行验收仍待完成。 - `./.agents/skills/write-stable-tests/scripts/test-stability-macos.sh -- --filter 'AgentConversationFeatureModelTests|AgentConversationPresentationTests'`:注入可推进的单调时钟验证排队、后台会话、取消确认、失败和断连;共享 fixture 校验用量、零值与缺失值,统计行的分组边界与历史回放不伪造。真实 Agent 的 token 口径和连续计时仍需人工验收,不能以合成数据截图代替供应商运行验证。 diff --git a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md index 23c3ee677..a42a26720 100644 --- a/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md +++ b/.agents/notes/implemented/process/2026-09-13-ci-build-cache-and-artifact-strategy.md @@ -79,6 +79,22 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 并发缓存主要缩短串行等待和反馈时间,不承诺减少总 runner 分钟;队列等待 和可用 runner 数量属于 CI 基础设施因素,不能与编译优化混为一谈。 +Windows 安装器失败时,Bun 可能已经退出,但并行的生命周期脚本仍在运行, +继续占用依赖目录;直接删除目录会让原本可以重试的下载故障变成文件锁错误。 +每次安装现在由独立 PowerShell worker 拥有:启动 Bun 前把 worker 加入 +Job Object(Windows 用于管理整棵子进程树的对象),设置最后一个句柄关闭时 +终止成员进程。句柄不继承给子进程,由 worker 的进程生命周期持有;worker +正常结束、失败或被取消时,系统释放句柄并终止残留脚本;每次安装还有默认 +300 秒的 worker 内部期限,超时退出码为 124,不能只依赖 CI 总超时。父安装器再清理 +部分缓存和依赖。文件系统释放锁可能稍晚,删除重试有单调计时的 10 秒期限, +超时明确失败,不无限等待。缓存清理后撤销旧 verified 标记,冷安装成功才 +重新生成完整性清单。只重试一次,不把永久安装错误隐藏成成功。 + +正确做法:worker 拥有 Bun 及其脚本,结束后再清理当前工作树生成目录; +不要按进程名结束所有 Node/Bun,因为用户其他工作树或应用可能正在使用它们。 +这些句柄和安装目录属于本次安装,不新增可复用资源;只有经过锁文件、Bun +版本与完整性清单校验的下载缓存可以跨工作树复制,安装包仍只读。 + ## 考虑过的备选方案 ### 在旧 runner 上用 Swiftly 安装独立编译器 @@ -98,6 +114,13 @@ PR 的测试合并提交必须在构建摘要中可追溯。被分类器选中 能减少网络等待,但无法覆盖 Rust Core 和数据库辅助 crate 的主要编译成本, 因此扩展为缓存 Cargo 的中间输出和 build script 结果。 +### 失败后仅限制安装并发或等待固定时间 + +不采用。相同 Bun 版本的本地生命周期探针表明,串行选项也不能保证失败后 +没有残留脚本;固定等待则无法证明进程结束。直接结束全部 Node 还会影响 +其他任务。Windows Job Object 提供系统级所有权和取消清理,代价是每次安装 +多启动一个 PowerShell worker,安装路径的回归测试需要在 Windows 上运行。 + ### 只构建一个 macOS 架构 可以降低 CI 成本,但无法发现另一架构上的编译、链接和打包问题。macOS 产品 @@ -151,6 +174,7 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `./scripts/build-official-plugins.sh --configuration debug --triple x86_64-apple-macosx` - `./scripts/verify-rust-core.sh` - `./scripts/verify-windows-boundaries.sh` +- `node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts`:Windows 无网络夹具用 IPC 确认子进程占用目录,再让安装失败,验证清理后冷重试成功、永久失败仍报错、成功退出也不留子进程、超时触发本地期限,且不清除有效缓存。 - `gh run download --repo 1lck/Lithe-IDEA --pattern 'Lithe-macos-*'` - `gh workflow run release-preview-windows.yml -f source_branch=` @@ -163,6 +187,9 @@ Swift 测试已经编译完整 Lithe 目标。再生成两个 DMG 会在普通 - `.github/workflows/ci-macos.yml` - `.github/workflows/ci-windows.yml` - `scripts/classify-ci-changes.sh` +- `scripts/install-windows-frontend-dependencies.ps1` +- `scripts/invoke-windows-bun-install.ps1` +- `scripts/windows-frontend-install.test.ts` - `scripts/test-classify-ci-changes.sh` - `scripts/build-macos.sh` - `scripts/build-official-plugins.sh` diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 6553e28f5..e98877102 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -123,6 +123,10 @@ jobs: shell: pwsh run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory frontend/editor --report .artifacts/test-stability/shared-editor.json + - name: Test Windows dependency install recovery + shell: pwsh + run: node .agents/skills/write-stable-tests/scripts/run-bun-tests-with-timing.mjs --working-directory . --max-ms 30000 --report .artifacts/test-stability/windows-dependency-install.json -- scripts/windows-frontend-install.test.ts + - name: Configure isolated dependency caches shell: pwsh run: | diff --git a/docs/ci-builds.md b/docs/ci-builds.md index ce828fe20..77a7fbb6a 100644 --- a/docs/ci-builds.md +++ b/docs/ci-builds.md @@ -171,6 +171,13 @@ SHA-256;Cargo、SwiftPM 和 Bun 使用各自的 lockfile、版本与完整性 `.swift-version` 和 `.lithe-integrity.json` 校验。 - `.artifacts/bun-cache/`:Bun 下载缓存;按 `bun.lock`、Bun 版本和缓存完整性 清单校验。 + Windows 的每次依赖安装在独立 worker 中执行;worker 在启动 Bun 前加入 + Job Object(Windows 用于管理整棵子进程树的对象),退出时终止残留安装脚本。 + 每次安装默认有 300 秒本地期限,超时终止 worker 及其子进程; + 安装失败后先释放子进程,再清理部分依赖与缓存,并只进行一次冷安装重试; + 文件锁释放有 10 秒本地期限。`node_modules`、两个 workspace 的依赖目录和 + `.artifacts/bun-tmp` 是安装过程的可变状态,不跨 worktree 复制;进程句柄只在 + worker 内存中存活,不增加下载目录,不写发行资源,也不影响签名或增量更新。 - `.artifacts/jdtls-downloads/`:JDTLS、Lombok、Java Debug/Test 和 license。 - `.artifacts/jdk-downloads/`:各平台与架构的 bundled JDK 下载归档。 - `.artifacts/php-language-server-downloads/`:按 diff --git a/docs/development/platform-parity-matrix.csv b/docs/development/platform-parity-matrix.csv index 43a6140c4..b3f5219bb 100644 --- a/docs/development/platform-parity-matrix.csv +++ b/docs/development/platform-parity-matrix.csv @@ -13,6 +13,10 @@ agent-claude-api-key-authentication,AI,Agent 对话,Claude API Key 鉴权:通 agent-thinking-level-labels,AI,Agent 对话,Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称,已实现,已验证,未实现,待验证,Agent,2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-approval-mode-presentation,AI,Agent 对话,Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义,已实现,待验证,未实现,待验证,Agent,中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。,,macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift; macos/Resources/en.lproj/Localizable.strings; macos/Resources/zh-Hans.lproj/Localizable.strings; macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift,shared/contracts/application-boundary.md agent-tool-activity-timeline,AI,Agent 对话,连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,windows/tauri/src/features +agent-reasoning-display,AI,Agent 对话,Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留,已实现,待验证,未实现,不适用,Agent,macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json; macos/Tests/LitheTests/AgentConversationPresentationTests.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift,windows/tauri/src/features +agent-plan-display,AI,Agent 对话,Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除,已实现,待验证,未实现,不适用,Agent,macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json,windows/tauri/src/features +agent-slash-commands,AI,Agent 对话,输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表,已实现,待验证,未实现,不适用,Agent,macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift; macos/Sources/Lithe/Views/Agent/AgentComposerView.swift; macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift; macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift; macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift,windows/tauri/src/features +agent-mode-sync,AI,Agent 对话,Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求,已实现,待验证,未实现,不适用,Agent,macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。,,macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; shared/fixtures/agent/acp-events-v1.json,windows/tauri/src/features agent-turn-statistics,AI,Agent 对话,发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情,已实现,待验证,部分实现,待验证,Agent,macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。,,macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift; macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift; macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift; macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift; macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift; macos/Tests/LitheTests/AgentConversationPresentationTests.swift,rust/lithe-agent-host/src/prompt.rs; rust/lithe-agent-host/src/tests.rs; shared/fixtures/agent/acp-events-v1.json 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 diff --git a/docs/development/platform-parity-matrix.md b/docs/development/platform-parity-matrix.md index 52360c051..2609cfe43 100644 --- a/docs/development/platform-parity-matrix.md +++ b/docs/development/platform-parity-matrix.md @@ -4,9 +4,9 @@ - 最后复核:2026-09-29 - 盘点状态:initial-static-inventory(根据 macOS Views/Application/Services、Windows features/extensions 和共享契约的代码入口进行初版盘点;未替代真实运行验收。) -- 功能项:126 -- macOS:实现:✅ 111 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 110 待验证,— 12 不适用 -- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 10 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 8 不适用 +- 功能项:130 +- macOS:实现:✅ 115 已实现,🟡 3 部分实现,❌ 7 未实现,🧩 5 平台专属;验证:✔️ 4 已验证,🔍 114 待验证,— 12 不适用 +- Windows:实现:✅ 102 已实现,🟡 12 部分实现,❌ 14 未实现,🧩 2 平台专属;验证:✔️ 0 已验证,🔍 118 待验证,— 12 不适用 ## 实现状态定义 @@ -30,7 +30,7 @@ > 每一行对应一个可以单独验收的用户能力;区域和功能组只用于导航,不作为状态统计单位。单元格第一行是实现状态,第二行是验证状态。
-AI · 24 个能力点 +AI · 28 个能力点 | 功能组 | 能力点 | macOS | Windows | 负责人 | 验证方式 | 备注 | | --- | --- | --- | --- | --- | --- | --- | @@ -48,6 +48,10 @@ | Agent 对话 | **Agent 思考强度英文呈现:Claude/Codex 子菜单、设置行当前值和模型底栏统一使用上游英文名称**
agent-thinking-level-labels | ✅ 已实现
✔️ 已验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 2026-10-01 macOS:最新 preview 独立 PR 分支 83 项 Agent 回归通过;含 PR #1007 前置修复的组合构建 87 项回归及真实界面验收通过;中文真实界面确认 Codex 的 Low、Medium、High、Xhigh、Max、Ultra 和 Claude 的 Low、Medium、High、Max 均显示英文。两边分别切换 Low,确认子菜单、设置行当前值和模型底栏同步英文,并恢复 Claude Medium、Codex Xhigh;权限、速度和思考强度标题仍显示中文。不改原始选项 ID、顺序和确认行为,运行前后 bundle 全部 990 个文件 SHA-256 一致。Windows 对话 UI 仍待实现。 | | | Agent 对话 | **Agent 权限模式呈现:Claude/Codex 名称保留上游原文,说明按界面语言显示并补齐图标,保留各 Agent 实际支持的选项及权限语义**
agent-approval-mode-presentation | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentSessionSelectors.swift`、`macos/Resources/en.lproj/Localizable.strings`、`macos/Resources/zh-Hans.lproj/Localizable.strings`、`macos/Tests/LitheTests/AgentSessionSelectorPresentationTests.swift` | ❌ 未实现
🔍 待验证
`shared/contracts/application-boundary.md` | Agent | 中文和英文环境分别验证 Claude/Codex:权限名称保留上游原文,Plan 菜单与底栏一致,已知权限 ID 显示对应图标,未知选项原文和默认图标不变;说明使用当前界面语言,保留各 Agent 实际的审批和环境约束。检查原始 ID、数量、顺序及确认/失败行为不变。使用指定语言的资源 Bundle 验证名称与通用 Plan 翻译不会串用;整合紧凑布局后检查双行说明和长列表滚动。Windows 对话 UI 待实现。 | | | Agent 对话 | **连续 Agent 工具调用(执行命令、列文件、读文件与编辑等)合并为默认折叠的时间线卡片,汇总总数与失败/中断/完成进度,并逐条展开输入、输出与文件位置**
agent-tool-activity-timeline | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentToolGroupView.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留**
agent-reasoning-display | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除**
agent-plan-display | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表**
agent-slash-commands | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerView.swift`、`macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift`、`macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift`、`macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。 | | +| Agent 对话 | **Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求**
agent-mode-sync | ✅ 已实现
🔍 待验证
`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`shared/fixtures/agent/acp-events-v1.json` | ❌ 未实现
— 不适用
`windows/tauri/src/features` | Agent | macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。 | | | Agent 对话 | **发送后实时计时、每轮结束固定耗时、Agent 上报的输入/输出 token 与缓存/推理用量详情**
agent-turn-statistics | ✅ 已实现
🔍 待验证
`macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift`、`macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentTurnStatistics.swift`、`macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift`、`macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift`、`macos/Tests/LitheTests/AgentConversationPresentationTests.swift` | 🟡 部分实现
🔍 待验证
`rust/lithe-agent-host/src/prompt.rs`、`rust/lithe-agent-host/src/tests.rs`、`shared/fixtures/agent/acp-events-v1.json` | Agent | macOS:在真实 Agent 会话中确认发送后计时包含建会话、加载历史、工具和权限等待;正常完成、取消确认、请求失败和断连后停止计时,下一轮仍可看到上一轮统计。并发会话切换验证耗时与用量隔离;上报 usage 时显示准确输入/输出并悬停查看总量、缓存和推理值,缺失时只显示耗时。历史回放不编造统计,上下文占用与订阅额度不作为输入/输出 token。检查深浅主题和窄宽面板。Windows 共享 Host 已透传可选 usage,对话 UI 尚未接入。 | | | 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 待接入设置界面。 | | diff --git a/macos/Resources/en.lproj/Localizable.strings b/macos/Resources/en.lproj/Localizable.strings index 309780e95..9b58b5600 100644 --- a/macos/Resources/en.lproj/Localizable.strings +++ b/macos/Resources/en.lproj/Localizable.strings @@ -1154,6 +1154,8 @@ "Search models" = "Search models"; "No matching models" = "No matching models"; "Thinking level" = "Thinking level"; +"Responding…" = "Responding…"; +"Plan %lld/%lld" = "Plan %lld/%lld"; "Speed" = "Speed"; "Fast" = "Fast"; "Approval mode" = "Approval mode"; diff --git a/macos/Resources/zh-Hans.lproj/Localizable.strings b/macos/Resources/zh-Hans.lproj/Localizable.strings index f5585d50c..112c6b06d 100644 --- a/macos/Resources/zh-Hans.lproj/Localizable.strings +++ b/macos/Resources/zh-Hans.lproj/Localizable.strings @@ -2242,6 +2242,7 @@ "Stop" = "停止"; "Deny" = "拒绝"; "Thinking…" = "思考中…"; +"Responding…" = "回复中…"; "Loading conversation…" = "正在加载对话…"; "Starting the Agent…" = "正在启动 Agent…"; "The Agent process starts when this panel opens." = "打开此面板时才会启动 Agent 进程。"; @@ -2259,6 +2260,26 @@ "Completed" = "已完成"; "Interrupted" = "已中断"; +/* Agent reasoning, plan and commands */ +"Thinking" = "思考"; +"Thinking process" = "思考过程"; +"Hide thinking" = "收起思考"; +"Show thinking" = "展开思考"; +"Plan %lld/%lld" = "计划 %lld/%lld"; +"Hide plan" = "收起计划"; +"Show plan" = "展开计划"; +"Agent commands" = "Agent 命令"; +"Tool call" = "工具调用"; +"Allow the Agent to continue?" = "允许 Agent 继续吗?"; +"The Agent request failed." = "Agent 请求失败。"; +"The response stopped at the model's token limit." = "回复已达到模型的 token 上限,已停止。"; +"The Agent stopped after reaching its request limit for this turn." = "Agent 已达到本轮请求次数上限,已停止。"; +"The Agent declined to continue." = "Agent 拒绝继续。"; +"Content" = "内容"; +"Unsupported content" = "不支持的内容"; +"Tool" = "工具"; +"You" = "你"; + /* Agent panel settings */ "Agent conversation" = "Agent 对话"; "Enable Agent conversation" = "启用 Agent 对话"; diff --git a/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift new file mode 100644 index 000000000..6a2ab49d6 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift @@ -0,0 +1,54 @@ +import LitheAgentConversationModule + +/// View-local command interaction shared by Return, Esc, arrow keys and Tab. +/// Completing a command edits the draft; only a send result starts a prompt. +struct AgentCommandCompletion { + enum Key { case submit, escape, up, down, tab } + enum Result { case ignored, handled, send, cancel } + + var draft = "" { + didSet { + if draft != oldValue { + highlightedIndex = 0 + dismissedDraft = nil + } + } + } + private(set) var highlightedIndex = 0 + private var dismissedDraft: String? + + func suggestions(in commands: [AgentCommand]) -> [AgentCommand]? { + dismissedDraft == draft ? nil : AgentCommand.suggestions(for: draft, in: commands) + } + + mutating func complete(_ command: AgentCommand) { + draft = command.invocation + " " + dismissedDraft = nil + } + + mutating func handle(_ key: Key, commands: [AgentCommand], isResponding: Bool) -> Result { + let suggestions = suggestions(in: commands) + switch key { + case .escape: + if suggestions != nil { + dismissedDraft = draft + return .handled + } + return isResponding ? .cancel : .ignored + case .submit: + guard let suggestions, !suggestions.isEmpty, + !suggestions.contains(where: { $0.invocation == draft }) else { return .send } + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + return .handled + case .up, .down, .tab: + guard let suggestions, !suggestions.isEmpty else { return .ignored } + if key == .tab { + complete(suggestions[min(highlightedIndex, suggestions.count - 1)]) + } else { + let offset = key == .up ? -1 : 1 + highlightedIndex = (min(highlightedIndex, suggestions.count - 1) + offset + suggestions.count) % suggestions.count + } + return .handled + } + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift new file mode 100644 index 000000000..b91083509 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift @@ -0,0 +1,50 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Fixed strips and the flexible editor use the same sizes when computing the +/// split minimum. Commands never increase that minimum; attachments do. +enum AgentComposerMetrics { + static let contextHeight: CGFloat = 30 + static let toolbarHeight: CGFloat = 38 + static let fileHeight: CGFloat = 34 + static let writingLineHeight: CGFloat = 36 + static let bottomInset: CGFloat = 8 + static let splitTopInset: CGFloat = 10 + + static func minimumHeight(hasFiles: Bool) -> CGFloat { + contextHeight + toolbarHeight + writingLineHeight + bottomInset + splitTopInset + (hasFiles ? fileHeight : 0) + } +} + +struct AgentComposerMinimumHeightKey: PreferenceKey { + static let defaultValue = AgentComposerMetrics.minimumHeight(hasFiles: false) + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = max(value, nextValue()) } +} + +/// The production composer layout, also hosted in native regression tests. +struct AgentComposerContent: View { + let files: [AgentFileReference] + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + let onRemoveFile: (String) -> Void + let onFocus: () -> Void + @ViewBuilder let context: () -> Context + @ViewBuilder let editor: () -> Editor + @ViewBuilder let toolbar: () -> Toolbar + + var body: some View { + VStack(spacing: 0) { + context() + if !files.isEmpty { AgentFileReferenceList(files: files, onRemove: onRemoveFile) } + AgentComposerDraftArea(commands: commands, highlightedIndex: highlightedIndex, onSelect: onSelect) { + ScrollView { editor() } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .contentShape(Rectangle()) + .onTapGesture(perform: onFocus) + } + toolbar() + } + .preference(key: AgentComposerMinimumHeightKey.self, value: AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty)) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift new file mode 100644 index 000000000..6044f9735 --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift @@ -0,0 +1,81 @@ +import SwiftUI +import LitheAgentConversationModule + +/// Reserves a writing line before allocating space to command suggestions. +struct AgentComposerDraftArea: View { + let commands: [AgentCommand]? + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + @ViewBuilder let editor: () -> Editor + + var body: some View { + GeometryReader { geometry in + let listHeight = max(0, geometry.size.height - AgentComposerMetrics.writingLineHeight) + let showsInline = listHeight >= AgentCommandSuggestionList.minimumHeight + VStack(spacing: 0) { + if let commands, showsInline { + suggestions(commands, maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, listHeight - 2)) + } + editor() + .frame(minHeight: AgentComposerMetrics.writingLineHeight, maxHeight: .infinity, alignment: .topLeading) + } + .anchorPreference(key: AgentFloatingCommandsKey.self, value: .bounds) { bounds in + guard let commands, !showsInline else { return nil } + return AgentFloatingCommands(bounds: bounds, + list: suggestions(commands, maximumHeight: AgentCommandSuggestionList.defaultMaximumHeight)) + } + } + .zIndex(1) + } + + private func suggestions(_ commands: [AgentCommand], maximumHeight: CGFloat) -> AgentCommandSuggestionList { + AgentCommandSuggestionList( + commands: commands, + highlightedIndex: min(highlightedIndex, max(0, commands.count - 1)), + onSelect: onSelect, + maximumHeight: maximumHeight + ) + } +} + +private struct AgentFloatingCommands { + let bounds: Anchor + let list: AgentCommandSuggestionList +} + +private struct AgentFloatingCommandsKey: PreferenceKey { + static var defaultValue: AgentFloatingCommands? + + static func reduce(value: inout AgentFloatingCommands?, nextValue: () -> AgentFloatingCommands?) { + value = nextValue() ?? value + } +} + +/// Render outside the split pane's hit bounds so floated rows remain clickable. +private struct AgentCommandSuggestionScope: ViewModifier { + func body(content: Content) -> some View { + content + .overlayPreferenceValue(AgentFloatingCommandsKey.self) { suggestion in + GeometryReader { geometry in + if let suggestion { + let frame = geometry[suggestion.bounds] + let list = AgentCommandSuggestionList( + commands: suggestion.list.commands, + highlightedIndex: suggestion.list.highlightedIndex, + onSelect: suggestion.list.onSelect, + maximumHeight: min(AgentCommandSuggestionList.defaultMaximumHeight, max(0, frame.minY - 2)) + ) + list.frame(width: frame.width) + .position(x: frame.midX, y: frame.minY - list.height / 2) + } + } + } + .transformPreference(AgentFloatingCommandsKey.self) { $0 = nil } + } +} + +extension View { + func agentCommandSuggestionScope() -> some View { + modifier(AgentCommandSuggestionScope()) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift index a03136fb1..205dfb26b 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentComposerView.swift @@ -24,36 +24,38 @@ struct AgentComposerView: View { var subscriptionAccount: String? var quotaFailure: String? var onSetConfig: (String, String) -> Void = { _, _ in } - @State private var draft = "" + var commands: [AgentCommand] = [] + @State private var completion = AgentCommandCompletion() @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 } + private var hasContent: Bool { !completion.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || !files.isEmpty } + private var commandSuggestions: [AgentCommand]? { + completion.suggestions(in: commands) + } var body: some View { - VStack(spacing: 0) { + AgentComposerContent(files: files, commands: commandSuggestions, + highlightedIndex: completion.highlightedIndex, + onSelect: complete, onRemoveFile: { id in files.removeAll { $0.id == id } }, + onFocus: { isFocused = true }) { 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 } + } editor: { + TextField("Message the Agent", text: $completion.draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1...) + .focused($isFocused) + .onSubmit { _ = handleCommandKey(.submit) } + .modifier(AgentCommandKeyNavigation(onKey: handleCommandKey)) + .padding(.horizontal, 8) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { toolbar } .background(AgentPanelStyle.canvas, in: RoundedRectangle(cornerRadius: 8)) @@ -76,9 +78,9 @@ struct AgentComposerView: View { } .onHover { isHovering = $0 } .padding(.horizontal, 8) - .padding(.bottom, 8) + .padding(.bottom, AgentComposerMetrics.bottomInset) .onAppear { isFocused = true } - .onExitCommand { if isResponding { onCancel() } } + .onExitCommand { _ = handleCommandKey(.escape) } } private var contextBar: some View { @@ -98,7 +100,7 @@ struct AgentComposerView: View { .font(.system(size: 11)) .foregroundStyle(AgentPanelStyle.secondary) .padding(.horizontal, 10) - .frame(height: 28) + .frame(height: AgentComposerMetrics.contextHeight - 2) .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } @@ -149,7 +151,7 @@ struct AgentComposerView: View { .help(isCancelling ? "Stopping…" : (isResponding ? "Stop" : "Send")) } .padding(.horizontal, 5) - .frame(height: 36) + .frame(height: AgentComposerMetrics.toolbarHeight - 2) .background(AgentPanelStyle.toolbar, in: RoundedRectangle(cornerRadius: 7)) .padding(1) } @@ -205,6 +207,22 @@ struct AgentComposerView: View { } } + private func complete(_ command: AgentCommand) { + completion.complete(command) + isFocused = true + } + + private func handleCommandKey(_ key: AgentCommandCompletion.Key) -> AgentCommandCompletion.Result { + let result = completion.handle(key, commands: commands, isResponding: isResponding) + switch result { + case .send: send() + case .cancel: onCancel() + case .handled: isFocused = true + case .ignored: break + } + return result + } + private func send() { guard hasContent, !isResponding else { return } if isBlocked || isPreparingSession { @@ -212,8 +230,8 @@ struct AgentComposerView: View { return } do { - try onSend(draft, files) - draft = "" + try onSend(completion.draft, files) + completion.draft = "" files.removeAll() onError(nil) } catch { @@ -223,23 +241,47 @@ struct AgentComposerView: View { } +/// Arrow keys and Tab drive the command list while it is open. Key handling on a +/// focused text field needs macOS 14; macOS 13 keeps mouse selection and Return. +private struct AgentCommandKeyNavigation: ViewModifier { + let onKey: (AgentCommandCompletion.Key) -> AgentCommandCompletion.Result + + func body(content: Content) -> some View { + if #available(macOS 14.0, *) { + content + .onKeyPress(.upArrow) { handle(.up) } + .onKeyPress(.downArrow) { handle(.down) } + .onKeyPress(.tab) { handle(.tab) } + } else { + content + } + } + + @available(macOS 14.0, *) + private func handle(_ key: AgentCommandCompletion.Key) -> KeyPress.Result { + onKey(key) == .ignored ? .ignored : .handled + } +} + /// The shared split container keeps resize updates outside the conversation model. struct AgentConversationLayout: View { @ViewBuilder let transcript: Transcript @ViewBuilder let composer: Composer + @State private var composerMinimumHeight = AgentComposerMetrics.minimumHeight(hasFiles: false) var body: some View { GeometryReader { geometry in + let minimum = min(composerMinimumHeight, max(0, geometry.size.height - SplitHandleView.hitThickness)) 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), + minimum: minimum, + maximum: max(minimum, geometry.size.height * 0.6), showsIdleDivider: false ) { composer - .padding(.top, 10) + .padding(.top, AgentComposerMetrics.splitTopInset) .overlay(alignment: .top) { Capsule().fill(AgentPanelStyle.muted.opacity(0.55)) .frame(width: 54, height: 3) @@ -250,5 +292,7 @@ struct AgentConversationLayout: View { transcript } } + .onPreferenceChange(AgentComposerMinimumHeightKey.self) { composerMinimumHeight = $0 } + .agentCommandSuggestionScope() } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift index f2535fb3e..c995dc874 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentConversationView.swift @@ -268,7 +268,8 @@ private struct AgentConnectionView: View { subscriptionQuota: feature.subscriptionQuota, subscriptionAccount: feature.subscriptionEmail, quotaFailure: feature.quotaFailure, - onSetConfig: { feature.setConfigOption($0, value: $1) } + onSetConfig: { feature.setConfigOption($0, value: $1) }, + commands: feature.selectedConversation?.availableCommands ?? [] ) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift index 950a8f921..1be1da97f 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentFileReferenceList.swift @@ -29,6 +29,6 @@ struct AgentFileReferenceList: View { .padding(.horizontal, 8) } .scrollIndicators(.hidden) - .frame(height: 34) + .frame(height: AgentComposerMetrics.fileHeight) } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift index 0eb5e6d4c..c0239dc2d 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentHistoryView.swift @@ -40,7 +40,7 @@ enum AgentHistoryPresentation { 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 + return conversation.messages.filter { $0.role == .user || $0.role == .agent }.count } } diff --git a/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift new file mode 100644 index 000000000..ff12f535a --- /dev/null +++ b/macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift @@ -0,0 +1,241 @@ +import SwiftUI +import LitheAgentConversationModule + +/// The agent's reasoning, separate from its reply. Open while it streams and +/// collapsed once the reply or a tool call follows; a search keeps it open. +struct AgentThoughtRow: View { + let text: String + let isStreaming: Bool + var isSearching = false + @Binding var expansion: AgentThoughtExpansion + + private var isExpanded: Bool { expansion.isExpanded(isStreaming: isStreaming, isSearching: isSearching) } + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Button { expansion.toggle(isStreaming: isStreaming, isSearching: isSearching) } label: { + HStack(spacing: 6) { + Image(systemName: "brain") + .font(.system(size: 10.5)) + Text(isStreaming ? "Thinking…" : "Thinking process") + .font(.system(size: 11.5, weight: .medium)) + Image(systemName: isExpanded ? "chevron.down" : "chevron.right") + .font(.system(size: 9)) + Spacer(minLength: 0) + } + .foregroundStyle(LitheTheme.tertiaryText) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .lithePointer() + .help(isExpanded ? "Hide thinking" : "Show thinking") + .accessibilityValue(isExpanded ? String(localized: "Expanded") : String(localized: "Collapsed")) + + if isExpanded { + Text(text.trimmingCharacters(in: .whitespacesAndNewlines)) + .font(.system(size: 12)) + .foregroundStyle(LitheTheme.secondaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 10) + .overlay(alignment: .leading) { + Rectangle().fill(LitheTheme.panelBorder).frame(width: 2) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// Search temporarily reveals a match without replacing the user's disclosure preference. +struct AgentThoughtExpansion { + private var userExpanded: Bool? + + func isExpanded(isStreaming: Bool, isSearching: Bool) -> Bool { + isSearching || (userExpanded ?? isStreaming) + } + + mutating func toggle(isStreaming: Bool, isSearching: Bool) { + // A matching search must keep its text visible, including after a click. + guard !isSearching else { return } + userExpanded = !isExpanded(isStreaming: isStreaming, isSearching: false) + } +} + +/// Plan the agent reported for this conversation, pinned above the activity bar. +/// Collapsed it shows progress and the current step; it stays expandable after the turn. +struct AgentPlanView: View { + let plan: AgentPlan + let isResponding: Bool + @State private var expanded = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Button { expanded.toggle() } label: { + HStack(spacing: 7) { + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 10.5)) + .foregroundStyle(plan.isComplete ? LitheTheme.success : LitheTheme.accent) + Text(String(format: String(localized: "Plan %lld/%lld"), plan.completedCount, plan.entries.count)) + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(LitheTheme.primaryText) + .monospacedDigit() + if !expanded, let current = plan.currentEntry { + Text(current.content) + .font(.system(size: 11.5)) + .foregroundStyle(LitheTheme.secondaryText) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 4) + Image(systemName: expanded ? "chevron.down" : "chevron.up") + .font(.system(size: 9)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .padding(.horizontal, 10) + .frame(height: 28) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .help(expanded ? "Hide plan" : "Show plan") + + if expanded { + Rectangle().fill(LitheTheme.panelBorder).frame(height: 1) + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(plan.entries.enumerated()), id: \.offset) { _, entry in + entryRow(entry) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + .frame(maxHeight: 180) + .fixedSize(horizontal: false, vertical: true) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.header, in: RoundedRectangle(cornerRadius: 5)) + .overlay(RoundedRectangle(cornerRadius: 5).stroke(AgentPanelStyle.border, lineWidth: 1)) + .padding(.horizontal, 18) + .padding(.bottom, 4) + } + + private func entryRow(_ entry: AgentPlan.Entry) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 7) { + Image(systemName: icon(entry.status)) + .font(.system(size: 10.5)) + .foregroundStyle(color(entry.status)) + Text(entry.content) + .font(.system(size: 11.5)) + .foregroundStyle(entry.status == .completed ? LitheTheme.tertiaryText : LitheTheme.primaryText) + .strikethrough(entry.status == .completed, color: LitheTheme.tertiaryText) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityElement(children: .combine) + .accessibilityValue(label(entry.status)) + } + + private func icon(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: "checkmark.circle.fill" + case .inProgress: isResponding ? "circle.dotted" : "circle.lefthalf.filled" + case .pending: "circle" + } + } + + private func color(_ status: AgentPlan.Entry.Status) -> Color { + switch status { + case .completed: LitheTheme.success + case .inProgress: LitheTheme.accent + case .pending: LitheTheme.tertiaryText + } + } + + private func label(_ status: AgentPlan.Entry.Status) -> String { + switch status { + case .completed: String(localized: "Completed") + case .inProgress: String(localized: "Running") + case .pending: String(localized: "Pending") + } + } +} + +/// Slash commands matching the draft, shown above the writing area. +struct AgentCommandSuggestionList: View { + let commands: [AgentCommand] + let highlightedIndex: Int + let onSelect: (AgentCommand) -> Void + var maximumHeight: CGFloat = defaultMaximumHeight + static let defaultMaximumHeight: CGFloat = 168 + /// One complete command row including the list's vertical padding and border. + static let minimumHeight = rowHeight + 2 * contentInset + 2 * borderInset + private static let rowHeight: CGFloat = 26 + private static let contentInset: CGFloat = 3 + private static let borderInset: CGFloat = 1 + + var height: CGFloat { + let contentHeight = commands.isEmpty ? Self.rowHeight + : min(maximumHeight, CGFloat(commands.count) * Self.rowHeight + 2 * Self.contentInset) + return contentHeight + 2 * Self.borderInset + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if commands.isEmpty { + Text("No matching commands") + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + } else { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in + row(command, isHighlighted: index == highlightedIndex).id(command.id) + } + } + .padding(.vertical, Self.contentInset) + } + .frame(maxHeight: maximumHeight) + .fixedSize(horizontal: false, vertical: true) + .onChange(of: highlightedIndex) { index in + if commands.indices.contains(index) { proxy.scrollTo(commands[index].id) } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .background(AgentPanelStyle.context, in: RoundedRectangle(cornerRadius: 7)) + .padding(Self.borderInset) + .frame(height: height) + .accessibilityLabel("Agent commands") + } + + private func row(_ command: AgentCommand, isHighlighted: Bool) -> some View { + Button { onSelect(command) } label: { + HStack(spacing: 8) { + Text(command.invocation) + .font(.system(size: 11.5, weight: .medium, design: .monospaced)) + .foregroundStyle(AgentPanelStyle.text) + .lineLimit(1) + .layoutPriority(1) + Text(command.description) + .font(.system(size: 11.5)) + .foregroundStyle(AgentPanelStyle.secondary) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .padding(.horizontal, 10) + .frame(height: Self.rowHeight) + .background(isHighlighted ? AgentPanelStyle.focus.opacity(0.18) : Color.clear) + .contentShape(Rectangle()) + } + .buttonStyle(.litheNoPress) + .litheRowHover() + .help(command.hint.map { "\(command.invocation) \($0)" } ?? command.description) + } +} diff --git a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift index f35837ec0..19f964643 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift @@ -76,12 +76,18 @@ struct AgentTranscriptView: View { var searchText = "" var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } @State private var showsAgentPicker = false + // A filtered-out row can be destroyed. Keep its preference in the owning + // transcript view, isolated by session and message, until the actual data goes away. + @State private var thoughtExpansions: [String: [String: AgentThoughtExpansion]] = [:] var body: some View { let conversation = feature.selectedConversation let messages = conversation?.messages ?? [] let transcript = AgentTranscriptItem.grouped(messages, turns: conversation?.completedTurns ?? []) .filter { $0.matches(searchText) } + // Only reasoning that is still streaming opens by default. + let liveThoughtID = conversation?.isResponding == true && messages.last?.role == .thought + ? messages.last?.id : nil VStack(spacing: 0) { if conversation?.isLoading != true && messages.isEmpty && feature.pendingNewConversationPrompt == nil { AgentHeroView(agentName: agentName, agentVersion: agentVersion) { @@ -128,7 +134,13 @@ struct AgentTranscriptView: View { Group { switch item { case .message(let message): - AgentMessageRow(message: message, onOpenFile: onOpenFile) + AgentMessageRow( + message: message, + isStreamingThought: liveThoughtID == message.id, + isSearching: !searchText.isEmpty, + thoughtExpansion: thoughtExpansion(for: message.id), + onOpenFile: onOpenFile + ) case .toolGroup(let tools): AgentToolGroupView(messages: tools, searchText: searchText, onOpenFile: onOpenFile) case .turnSummary(let turn): @@ -144,7 +156,8 @@ struct AgentTranscriptView: View { if conversation?.isResponding == true || feature.isCreatingSession { AgentThinkingRow( isCancelling: conversation?.isCancelling == true, - startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt + startedAt: conversation?.activeTurn?.startedAt ?? feature.pendingNewConversationStartedAt, + hasStreamingThought: liveThoughtID != nil ).id("responding") } } @@ -169,8 +182,29 @@ struct AgentTranscriptView: View { if let permission = conversation?.permission { AgentPermissionCard(permission: permission, answer: { feature.answerPermission(optionID: $0) }, onOpenFile: onOpenFile) } + if let plan = conversation?.plan { + AgentPlanView(plan: plan, isResponding: conversation?.isResponding == true) + .id(feature.selectedSessionID) + } AgentActivitySummaryBar(messages: messages) } + .onChange(of: feature.openSessionIDs) { sessionIDs in + thoughtExpansions = thoughtExpansions.filter { sessionIDs.contains($0.key) } + } + .onChange(of: messages.map(\.id)) { messageIDs in + // Use unfiltered messages: changing the search must never discard preferences. + guard let sessionID = feature.selectedSessionID, let preferences = thoughtExpansions[sessionID] else { return } + let retainedIDs = Set(messageIDs) + thoughtExpansions[sessionID] = preferences.filter { retainedIDs.contains($0.key) } + } + } + + private func thoughtExpansion(for messageID: String) -> Binding { + guard let sessionID = feature.selectedSessionID else { return .constant(AgentThoughtExpansion()) } + return Binding( + get: { thoughtExpansions[sessionID]?[messageID] ?? AgentThoughtExpansion() }, + set: { thoughtExpansions[sessionID, default: [:]][messageID] = $0 } + ) } } @@ -310,6 +344,9 @@ private struct AgentPermissionCard: View { private struct AgentMessageRow: View { let message: AgentConversationMessage + var isStreamingThought = false + var isSearching = false + var thoughtExpansion: Binding = .constant(AgentThoughtExpansion()) var onOpenFile: (AgentToolDetails.Location) -> Void = { _ in } var body: some View { @@ -327,6 +364,9 @@ private struct AgentMessageRow: View { case .agent: AgentMarkdownMessage(text: message.text) .frame(maxWidth: .infinity, alignment: .leading) + case .thought: + AgentThoughtRow(text: message.text, isStreaming: isStreamingThought, isSearching: isSearching, + expansion: thoughtExpansion) case .tool: AgentToolGroupView(messages: [message], searchText: "", onOpenFile: onOpenFile) } diff --git a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift index cb1449a4c..c8080073c 100644 --- a/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift +++ b/macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift @@ -39,12 +39,18 @@ enum AgentTurnStatisticsPresentation { struct AgentThinkingRow: View { var isCancelling = false var startedAt: ContinuousClock.Instant? + var hasStreamingThought = false + + var status: String { + if isCancelling { return String(localized: "Stopping…") } + return hasStreamingThought ? String(localized: "Responding…") : String(localized: "Thinking…") + } var body: some View { TimelineView(.periodic(from: .now, by: 1)) { _ in HStack(spacing: 8) { ProgressView().controlSize(.small) - Text(isCancelling ? "Stopping…" : "Thinking…") + Text(status) if let startedAt { let elapsed = AgentTurnStatistics(id: "waiting", startedAt: startedAt).elapsed(at: .now) Text(AgentTurnStatisticsPresentation.duration(elapsed)).monospacedDigit() diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift index b3d8f9c83..683731efd 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift @@ -54,7 +54,9 @@ public final class AgentConnectionModel: ObservableObject { /// session ID being loaded. private var queuedPrompts: [String: AgentPrompt] = [:] private var loadTokens: [String: String] = [:] - private var pendingText: [String: String] = [:] + /// Streamed text not yet shown, per session. One buffer holds one role so + /// interleaved reasoning and reply chunks become separate messages. + private var pendingText: [String: (role: AgentConversationMessage.Role, text: String)] = [:] private var flushTask: Task? private var needsAttention = false @Published private var createToken: String? @@ -403,7 +405,7 @@ public final class AgentConnectionModel: ObservableObject { 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.") + requestFailed(token: token, sessionID: sessionID, message: event["message"] as? String ?? String(localized: "The Agent request failed.")) case "stopped": let old = detachConnection(failure: event["message"] as? String) if let old { @@ -497,8 +499,24 @@ public final class AgentConnectionModel: ObservableObject { applyConfiguration(update["configOptions"], to: sessionID) case "agent_message_chunk": guard let text = Self.text(of: update) else { return } - pendingText[sessionID, default: ""] += text - scheduleFlush() + buffer(text, role: .agent, in: sessionID) + case "agent_thought_chunk": + guard let text = Self.text(of: update) else { return } + buffer(text, role: .thought, in: sessionID) + case "plan": + guard let plan = AgentPlan.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].plan = plan.entries.isEmpty ? nil : plan + case "available_commands_update": + guard let commands = AgentCommand.parse(update) else { return } + conversations[sessionID, default: AgentConversation()].availableCommands = commands + case "current_mode_update": + // Agents that expose modes as a config option may switch on their own, + // e.g. leaving plan mode; keep the selector on the reported mode. + guard let modeID = update["currentModeId"] as? String, + let index = conversations[sessionID]?.configOptions.firstIndex(where: { + $0.category == "mode" && $0.choices.contains { $0.id == modeID } + }) else { return } + conversations[sessionID]?.configOptions[index].currentValue = modeID case "user_message_chunk": guard let text = Self.text(of: update) else { return } flushPendingText() @@ -552,7 +570,7 @@ public final class AgentConnectionModel: ObservableObject { conversation.messages.append(AgentConversationMessage( id: id, role: .tool, - text: title ?? "Tool call", + text: title ?? String(localized: "Tool call"), toolStatus: status ?? .pending )) conversation.messages[conversation.messages.count - 1].toolDetails.merge(update) @@ -568,7 +586,7 @@ public final class AgentConnectionModel: ObservableObject { } var prompt = AgentPermissionPrompt( id: requestID, - title: tool?["title"] as? String ?? "Allow the Agent to continue?", + title: tool?["title"] as? String ?? String(localized: "Allow the Agent to continue?"), choices: options ) if let tool { @@ -609,7 +627,7 @@ public final class AgentConnectionModel: ObservableObject { 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.") + requestFailed(token: token, sessionID: sessionID, message: errorMessage ?? String(localized: "The Agent request failed.")) } } @@ -665,6 +683,7 @@ public final class AgentConnectionModel: ObservableObject { for id in conversations.keys { conversations[id]?.finishTurn(at: now()) conversations[id]?.contextUsage = nil + conversations[id]?.availableCommands = [] conversations[id]?.isResponding = false conversations[id]?.interruptPendingTools() conversations[id]?.isCancelling = false @@ -716,19 +735,25 @@ public final class AgentConnectionModel: ObservableObject { } } + private func buffer(_ text: String, role: AgentConversationMessage.Role, in sessionID: String) { + if let pending = pendingText[sessionID], pending.role != role { flushPendingText() } + pendingText[sessionID, default: (role, "")].text += text + scheduleFlush() + } + private func flushPendingText() { let pending = pendingText pendingText.removeAll() - for (sessionID, text) in pending where !text.isEmpty { - append(text, role: .agent, to: sessionID) + for (sessionID, chunk) in pending where !chunk.text.isEmpty { + append(chunk.text, role: chunk.role, 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." + case "max_tokens": String(localized: "The response stopped at the model's token limit.") + case "max_turn_requests": String(localized: "The Agent stopped after reaching its request limit for this turn.") + case "refusal": String(localized: "The Agent declined to continue.") default: nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift index 15de2e934..75c465516 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentConversationState.swift @@ -14,7 +14,8 @@ public struct AgentSessionSummary: Identifiable, Equatable, Sendable { } public struct AgentConversationMessage: Identifiable, Equatable, Sendable { - public enum Role: Equatable, Sendable { case user, agent, tool } + /// `thought` holds the agent's streamed reasoning, kept apart from its reply. + public enum Role: Equatable, Sendable { case user, agent, thought, tool } public enum ToolStatus: String, Equatable, Sendable { case pending case inProgress = "in_progress" @@ -55,6 +56,10 @@ public struct AgentPermissionPrompt: Identifiable, Equatable, Sendable { public struct AgentConversation: Equatable, Sendable { public var messages: [AgentConversationMessage] = [] public var contextUsage: AgentContextUsage? + /// Latest complete plan from the agent; history replay restores it. + public var plan: AgentPlan? + /// Slash commands the agent currently advertises for this session. + public var availableCommands: [AgentCommand] = [] public var activeTurn: AgentTurnStatistics? /// Local statistics survive tab switches and disconnects, but are not fabricated /// when the Agent replays history without timing or usage records. diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift index 709ba8683..8ac223414 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentHistoryFeatureModel.swift @@ -137,9 +137,14 @@ public struct AgentHistoryDocument: Sendable { switch message.role { case .user: role = String(localized: "You") case .agent: role = "Agent" + case .thought: role = String(localized: "Thinking") case .tool: role = String(localized: "Tool") } - var content = "## \(role)\n\n\(message.text)" + // Reasoning is exported as a quote so it stays distinct from the reply. + let body = message.role == .thought + ? message.text.components(separatedBy: .newlines).map { "> " + $0 }.joined(separator: "\n") + : message.text + var content = "## \(role)\n\n\(body)" 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)" diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift index 0f331848d..c3f882849 100644 --- a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionConfiguration.swift @@ -81,16 +81,17 @@ public struct AgentToolDetails: Equatable, Sendable { 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: String(localized: "Output"), text: Self.bounded(text)) } - return Content(title: "Content", text: block["type"] as? String ?? "Unsupported content") + return Content(title: String(localized: "Content"), + text: block["type"] as? String ?? String(localized: "Unsupported content")) case "diff": let old = item["oldText"] as? String ?? "" let new = item["newText"] as? String ?? "" - return Content(title: item["path"] as? String ?? "Diff", + return Content(title: item["path"] as? String ?? String(localized: "Diff"), text: Self.bounded("---\n" + old + "\n+++\n" + new)) case "terminal": - return Content(title: "Terminal", text: item["terminalId"] as? String ?? "") + return Content(title: String(localized: "Terminal"), text: item["terminalId"] as? String ?? "") default: return nil } } diff --git a/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift new file mode 100644 index 000000000..d9442abed --- /dev/null +++ b/macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift @@ -0,0 +1,101 @@ +import Foundation + +/// The agent's current execution plan. ACP sends the complete plan on every +/// `plan` update, so a new value replaces the previous one. +public struct AgentPlan: Equatable, Sendable { + public struct Entry: Equatable, Sendable { + public enum Status: String, Equatable, Sendable { + case pending + case inProgress = "in_progress" + case completed + } + + public var content: String + /// Upstream priority label (`high`, `medium`, `low`); kept as reported. + public var priority: String? + public var status: Status + + public init(content: String, priority: String? = nil, status: Status) { + self.content = content + self.priority = priority + self.status = status + } + } + + public var entries: [Entry] + + public init(entries: [Entry]) { self.entries = entries } + + public var completedCount: Int { entries.filter { $0.status == .completed }.count } + public var isComplete: Bool { !entries.isEmpty && completedCount == entries.count } + /// The entry the agent is working on, or the next pending one. + public var currentEntry: Entry? { + entries.first { $0.status == .inProgress } ?? entries.first { $0.status == .pending } + } + + static let entryLimit = 100 + + /// An empty entry list is a valid update that clears the plan. + static func parse(_ update: [String: Any]) -> Self? { + guard let entries = update["entries"] as? [[String: Any]] else { return nil } + return Self(entries: entries.prefix(entryLimit).compactMap { entry in + guard let content = entry["content"] as? String, + let status = (entry["status"] as? String).flatMap(Entry.Status.init(rawValue:)) else { return nil } + return Entry(content: content, priority: entry["priority"] as? String, status: status) + }) + } +} + +/// A slash command the agent advertised for this session. Selecting one only +/// inserts `/name ` into the prompt; the agent interprets the sent text. +public struct AgentCommand: Identifiable, Equatable, Sendable { + public var name: String + public var description: String + /// Placeholder for the free-form input that follows the command name. + public var hint: String? + + public var id: String { name } + /// Codex lists skills as `$name` commands; they are mentioned as `$name`, not `/$name`. + public var isSkill: Bool { name.hasPrefix("$") } + /// Text the composer inserts to invoke the command. + public var invocation: String { isSkill ? name : "/" + name } + + public init(name: String, description: String, hint: String? = nil) { + self.name = name + self.description = description + self.hint = hint + } + + static let commandLimit = 200 + + static func parse(_ update: [String: Any]) -> [Self]? { + guard let commands = update["availableCommands"] as? [[String: Any]] else { return nil } + // A command name is its invocation and UI identity. Keep the first + // advertised definition when an upstream list repeats the same name. + var names = Set() + return commands.prefix(commandLimit).compactMap { command in + guard let name = command["name"] as? String, !name.isEmpty, + names.insert(name).inserted else { return nil } + let input = command["input"] as? [String: Any] + return Self(name: name, description: command["description"] as? String ?? "", + hint: (input?["hint"] as? String).flatMap { $0.isEmpty ? nil : $0 }) + } + } + + /// Commands matching the token being typed, or nil when the draft is not a + /// command prefix. `/` lists every command; `$` lists only skills, and only + /// when the agent advertises some, so a literal `$` is not intercepted. + public static func suggestions(for draft: String, in commands: [Self]) -> [Self]? { + guard let trigger = draft.first, trigger == "/" || trigger == "$" else { return nil } + let candidates = trigger == "$" ? commands.filter(\.isSkill) : commands + guard !candidates.isEmpty else { return nil } + let query = draft.dropFirst().lowercased() + guard !query.contains(where: \.isWhitespace) else { return nil } + func key(_ command: Self) -> String { + (command.isSkill ? String(command.name.dropFirst()) : command.name).lowercased() + } + let prefixed = candidates.filter { key($0).hasPrefix(query) } + let contained = candidates.filter { !key($0).hasPrefix(query) && key($0).contains(query) } + return prefixed + contained + } +} diff --git a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift index 451b897b7..a9f8a9080 100644 --- a/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift +++ b/macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift @@ -309,6 +309,146 @@ struct AgentConversationFeatureModelTests { #expect(feature.sessions.first?.title == "Project overview") } + @Test + func interleavedReasoningAndReplyBecomeSeparateMessagesInArrivalOrder() throws { + let (feature, _) = try respondingFeature() + // Thought and reply chunks share one flush window; a role change must + // still end the previous segment instead of merging the texts. + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("agentThoughtChunk", ["update": ["sessionUpdate": "agent_thought_chunk", + "content": ["type": "text", "text": " Then test."]]])) + try feature.receive(event("agentMessageChunk")) + try feature.receive(event("toolCall")) + try feature.receive(event("agentThoughtChunk")) + try feature.receive(event("turnFinished")) + + let messages = try #require(feature.selectedConversation?.messages) + #expect(messages.map(\.role) == [.user, .thought, .agent, .tool, .thought]) + #expect(messages[1].text == "Read the manifest first. Then test.") + #expect(messages[2].text == "This project **builds** an IDE.") + #expect(messages[4].text == "Read the manifest first.") + let document = AgentHistoryDocument(id: "session-1", title: "Sample", messages: messages) + let markdown = AgentHistoryDocument.markdown([document]) + #expect(markdown.contains("> Read the manifest first. Then test.")) + #expect(markdown.contains("This project **builds** an IDE.")) + #expect(!markdown.contains("> This project")) + } + + @Test + func planUpdatesReplaceTheWholePlanAndAnEmptyPlanClearsIt() throws { + let (feature, _) = try respondingFeature() + try feature.receive(event("plan")) + let plan = try #require(feature.selectedConversation?.plan) + #expect(plan.entries.map(\.status) == [.completed, .inProgress, .pending]) + #expect(plan.entries.first?.priority == "high") + #expect(plan.completedCount == 1) + #expect(plan.currentEntry?.content == "Run the tests") + #expect(!plan.isComplete) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [ + ["content": "Summarize the results", "priority": "low", "status": "completed"], + ["content": "Unknown status", "priority": "low", "status": "blocked"] + ]]])) + let replaced = try #require(feature.selectedConversation?.plan) + #expect(replaced.entries.map(\.content) == ["Summarize the results"]) + #expect(replaced.isComplete) + #expect(replaced.currentEntry == nil) + + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan", "entries": [] as [Any]]])) + #expect(feature.selectedConversation?.plan == nil) + // A malformed update leaves the plan state unchanged rather than inventing one. + try feature.receive(event("plan", ["update": ["sessionUpdate": "plan"]])) + #expect(feature.selectedConversation?.plan == nil) + } + + @Test + func advertisedCommandsArePerSessionAndFilterOnlyWhileTypingAName() throws { + let (feature, connection) = try respondingFeature() + try feature.receive(event("availableCommands")) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.map(\.name) == ["review", "compact"]) + #expect(commands[0].hint == "optional focus") + #expect(commands[1].hint == nil) + + #expect(AgentCommand.suggestions(for: "/", in: commands)?.map(\.name) == ["review", "compact"]) + #expect(AgentCommand.suggestions(for: "/CO", in: commands)?.map(\.name) == ["compact"]) + // Prefix matches rank before names that only contain the query. + #expect(AgentCommand.suggestions(for: "/e", in: commands)?.map(\.name) == ["review"]) + #expect(AgentCommand.suggestions(for: "/zz", in: commands)?.isEmpty == true) + #expect(AgentCommand.suggestions(for: "/review ", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "review", in: commands) == nil) + #expect(AgentCommand.suggestions(for: "/", in: []) == nil) + #expect(commands.map(\.invocation) == ["/review", "/compact"]) + // A dollar sign is ordinary text unless the agent advertises skills. + #expect(AgentCommand.suggestions(for: "$", in: commands) == nil) + + // codex-acp lists skills as `$name` commands and ignores `/$name`, so a + // skill is inserted as a `$` mention and `$` filters to skills only. + let withSkills = commands + [AgentCommand(name: "$pdf", description: "Read PDF files"), + AgentCommand(name: "$spreadsheets", description: "Edit sheets")] + #expect(withSkills[2].isSkill && !withSkills[0].isSkill) + #expect(withSkills[2].invocation == "$pdf") + #expect(AgentCommand.suggestions(for: "$", in: withSkills)?.map(\.name) == ["$pdf", "$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "$sp", in: withSkills)?.map(\.name) == ["$spreadsheets"]) + #expect(AgentCommand.suggestions(for: "/pd", in: withSkills)?.map(\.name) == ["$pdf"]) + #expect(AgentCommand.suggestions(for: "$pdf ", in: withSkills) == nil) + + // Another session has its own list; the command text is sent as typed. + feature.startNewConversation() + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "sessionId": "session-2"])) + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.send("/review src") + #expect(connection.commands.last?["text"] as? String == "/review src") + try feature.receive(event("availableCommands", ["sessionId": "session-2", "update": [ + "sessionUpdate": "available_commands_update", "availableCommands": [] as [Any] + ]])) + #expect(feature.conversations["session-1"]?.availableCommands.count == 2) + } + + @Test + func duplicateAdvertisedCommandsKeepTheFirstDefinitionAndUniqueRowIdentities() throws { + let (feature, _) = try respondingFeature() + let advertised: [[String: Any]] = [ + ["name": "review", "description": "First definition", "input": ["hint": "focus"]], + ["name": "review", "description": "Repeated definition"], + ["name": "compact", "description": "Compact the conversation"] + ] + (0..<201).map { ["name": "command-\($0)"] } + try feature.receive(event("availableCommands", ["update": [ + "sessionUpdate": "available_commands_update", "availableCommands": advertised + ]])) + let commands = try #require(feature.selectedConversation?.availableCommands) + #expect(commands.count == 199, "Only the first 200 upstream entries are consumed") + #expect(Set(commands.map(\.id)).count == commands.count) + #expect(commands.prefix(2).map(\.name) == ["review", "compact"]) + #expect(commands[0].description == "First definition") + #expect(commands[0].hint == "focus") + #expect(AgentCommand.suggestions(for: "/re", in: commands)?.count == 1) + } + + @Test + func agentReportedModeChangeMovesOnlyTheMatchingModeSelector() throws { + let (feature, connection) = try connectedFeature() + feature.prepareConversation() + let configured = try #require(JSONSerialization.jsonObject(with: Data(event("sessionConfigured").utf8)) as? [String: Any]) + try feature.receive(event("sessionCreated", ["token": connection.commands.last?["token"] as Any, + "configOptions": configured["configOptions"] as Any])) + func current(_ category: String) -> String? { + feature.selectedConversation?.configOptions.first { $0.category == category }?.currentValue + } + #expect(current("mode") == "read-only") + let commandCount = connection.commands.count + try feature.receive(event("currentModeUpdate")) + #expect(current("mode") == "auto") + #expect(current("model") == "example-model") + // The agent already switched; echoing it back as a request would be wrong. + #expect(connection.commands.count == commandCount) + // A mode the selector does not offer is ignored rather than shown as a raw id. + try feature.receive(event("currentModeUpdate", ["update": ["sessionUpdate": "current_mode_update", + "currentModeId": "unlisted"]])) + #expect(current("mode") == "auto") + } + @Test func permissionChoicesAreAnsweredOrRejectedByCancel() throws { let (feature, connection) = try respondingFeature() @@ -403,11 +543,17 @@ struct AgentConversationFeatureModelTests { try feature.send("Explain this project") try feature.receive(event("sessionCreated", ["token": transport.connections[0].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands")) + #expect(feature.selectedConversation?.availableCommands.count == 2) + try feature.receive(event("availableCommands", ["sessionId": "session-2"])) + #expect(feature.conversations["session-2"]?.availableCommands.count == 2) + 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(feature.conversations.values.allSatisfy { $0.availableCommands.isEmpty }) #expect(throws: AgentConversationError.notConnected) { try feature.send("again") } await feature.stop() @@ -416,7 +562,13 @@ struct AgentConversationFeatureModelTests { try feature.receive(event("ready")) try feature.send("again") #expect(transport.connections[1].commands.last?["kind"] as? String == "loadSession") + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) + try feature.receive(event("sessionLoaded", ["token": transport.connections[1].commands.last?["token"] as Any])) + try feature.receive(event("availableCommands", ["update": ["sessionUpdate": "available_commands_update", + "availableCommands": [["name": "new-process-command", "description": "Current capabilities"]]]])) + #expect(feature.selectedConversation?.availableCommands.map(\.name) == ["new-process-command"]) await feature.stop() + #expect(feature.selectedConversation?.availableCommands.isEmpty == true) #expect(transport.connections[1].closeCount == 1) } diff --git a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift index ccde395e4..049aebb15 100644 --- a/macos/Tests/LitheTests/AgentConversationPresentationTests.swift +++ b/macos/Tests/LitheTests/AgentConversationPresentationTests.swift @@ -104,6 +104,228 @@ struct AgentConversationPresentationTests { #expect(AgentTranscriptItem.grouped(updated).map(\.id) == grouped.map(\.id)) } + @Test + func planReasoningAndCommandListsFitNarrowAndWidePanelsInBothAppearances() throws { + let plan = AgentPlan(entries: [ + .init(content: "Read the manifest and the build scripts of the sample project", priority: "high", status: .completed), + .init(content: "Run the tests", priority: "medium", status: .inProgress), + .init(content: "Summarize the results", priority: "low", status: .pending) + ]) + let commands = [AgentCommand(name: "review", description: "Review the current changes before committing", hint: "optional focus"), + AgentCommand(name: "compact", description: "Summarize the conversation to free context", hint: nil)] + for (name, scheme, width) in [("dark-narrow", ColorScheme.dark, 280.0), ("light-narrow", .light, 280.0), + ("dark-wide", .dark, 620.0), ("light-wide", .light, 620.0)] { + let host = NSHostingView(rootView: VStack(alignment: .leading, spacing: 10) { + AgentThoughtRow(text: "The manifest names the entry point, so read it first.", isStreaming: true, + expansion: .constant(AgentThoughtExpansion())) + AgentCommandSuggestionList(commands: commands, highlightedIndex: 0, onSelect: { _ in }) + AgentPlanView(plan: plan, isResponding: true) + }.padding(12).frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, scheme)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 260), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: 260) + host.layoutSubtreeIfNeeded() + #expect(host.fittingSize.height <= 260, "Reasoning, commands and plan summary must fit a short panel") + if let folder = ProcessInfo.processInfo.environment["LITHE_AGENT_STATISTICS_SCREENSHOTS"] { + let bitmap = try #require(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("guidance-\(name).png")) + } + } + } + + @Test + func reasoningSplitsToolTimelinesAndIsSearchableButNotCountedAsAMessage() throws { + let messages = [ + AgentConversationMessage(id: "list", role: .tool, text: "List files"), + AgentConversationMessage(id: "why", role: .thought, text: "The manifest names the entry point"), + AgentConversationMessage(id: "read", role: .tool, text: "Read file"), + AgentConversationMessage(id: "reply", role: .agent, text: "Done") + ] + let items = AgentTranscriptItem.grouped(messages) + #expect(items.map(\.id) == ["tools:list", "why", "tools:read", "reply"]) + #expect(items.filter { $0.matches("entry point") }.map(\.id) == ["why"]) + + var conversation = AgentConversation() + conversation.isAttached = true + conversation.messages = [AgentConversationMessage(role: .user, text: "Explain")] + messages + #expect(AgentHistoryPresentation.messageCount(conversation) == 2) + } + + @Test + func thoughtSearchRevealsAManuallyCollapsedMatchAndRestoresItsPreference() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + expansion.toggle(isStreaming: true, isSearching: false) + #expect(!expansion.isExpanded(isStreaming: true, isSearching: false)) + + // Search changes the effective disclosure without replacing the stored preference. + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + expansion.toggle(isStreaming: false, isSearching: true) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func thoughtStreamingCollapsesAutomaticallyButKeepsManualExpansion() { + var expansion = AgentThoughtExpansion() + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: true)) + #expect(!expansion.isExpanded(isStreaming: false, isSearching: false)) + expansion.toggle(isStreaming: false, isSearching: false) + #expect(expansion.isExpanded(isStreaming: true, isSearching: false)) + #expect(expansion.isExpanded(isStreaming: false, isSearching: false)) + } + + @Test + func commandKeysCompleteWithoutSendingAndSubmitFullInvocations() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact"), + AgentCommand(name: "$pdf", description: "Read PDFs")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 2) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.highlightedIndex == 0) + #expect(completion.handle(.down, commands: commands, isResponding: false) == .handled) + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/compact ") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + + completion.draft = "/re" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/review" + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + #expect(completion.draft == "/review") + completion.draft = "$pd" + #expect(completion.handle(.tab, commands: commands, isResponding: false) == .handled) + #expect(completion.draft == "$pdf ") + } + + @Test + func escapeDismissesSuggestionsBeforeCancellingAndEditingReopensThem() { + let commands = [AgentCommand(name: "review", description: "Review")] + var completion = AgentCommandCompletion() + completion.draft = "/" + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.draft == "/") + #expect(completion.suggestions(in: commands) == nil) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + #expect(completion.handle(.escape, commands: commands, isResponding: false) == .ignored) + completion.draft = "/r" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/" + #expect(completion.suggestions(in: commands)?.count == 1) + completion.draft = "/missing" + #expect(completion.suggestions(in: commands)?.isEmpty == true) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .handled) + #expect(completion.handle(.escape, commands: commands, isResponding: true) == .cancel) + } + + @Test + func unavailableCommandKeysLeaveOrdinaryTypingAndChangedListsUsable() { + let commands = [AgentCommand(name: "review", description: "Review"), + AgentCommand(name: "compact", description: "Compact")] + var completion = AgentCommandCompletion() + for draft in ["text", "$", "/missing", "/review argument"] { + completion.draft = draft + for key in [AgentCommandCompletion.Key.up, .down, .tab] { + #expect(completion.handle(key, commands: commands, isResponding: false) == .ignored) + #expect(completion.draft == draft) + } + #expect(completion.handle(.submit, commands: commands, isResponding: false) == .send) + } + completion.draft = "/" + #expect(completion.handle(.up, commands: commands, isResponding: false) == .handled) + // A fresh upstream list can be shorter while the same draft is focused. + #expect(completion.handle(.tab, commands: Array(commands.prefix(1)), isResponding: false) == .handled) + #expect(completion.draft == "/review ") + completion.draft = "/" + #expect(completion.handle(.tab, commands: [], isResponding: false) == .ignored) + } + + @Test + func activeThoughtKeepsOneThinkingLabelAndTheWaitingTimer() { + #expect(AgentThinkingRow().status == String(localized: "Thinking…")) + #expect(AgentThinkingRow(hasStreamingThought: true).status == String(localized: "Responding…")) + #expect(AgentThinkingRow(isCancelling: true, hasStreamingThought: true).status == String(localized: "Stopping…")) + } + + @Test + func commandSuggestionsKeepAWritingLineAtDefaultAndMinimumComposerHeights() throws { + let commands = (0..<200).map { + AgentCommand(name: "command-\($0)", description: "An upstream command with a long description", hint: nil) + } + let attachment = try AgentFileReference(url: URL(fileURLWithPath: "/example/project/README.md")) + for height in [400.0, 700.0] { + for width in [280.0, 620.0] { + for files in [[], [attachment]] { + for count in [0, 1, 40, 200] { + var editor: NSView? + var composer: NSView? + let host = NSHostingView(rootView: AgentConversationLayout { + Color.clear + } composer: { + AgentComposerContent(files: files, commands: Array(commands.prefix(count)), highlightedIndex: 0, + onSelect: { _ in }, onRemoveFile: { _ in }, onFocus: {}) { + Color.clear.frame(height: AgentComposerMetrics.contextHeight) + } editor: { + TextField("Message the Agent", text: .constant("/"), axis: .vertical) + .textFieldStyle(.plain).font(.system(size: 13)).lineLimit(1...) + .background(AgentDraftFrameProbe { editor = $0 }) + .padding(.horizontal, 8).padding(.vertical, 10) + .frame(maxWidth: .infinity, alignment: .topLeading) + } toolbar: { + Color.clear.frame(height: AgentComposerMetrics.toolbarHeight) + } + .padding(.horizontal, 8).padding(.bottom, AgentComposerMetrics.bottomInset) + .background(AgentDraftFrameProbe { composer = $0 }) + }) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: height), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.close() } + window.contentView = host + host.frame.size = NSSize(width: width, height: height) + host.layoutSubtreeIfNeeded() + let input = try #require(editor) + let frame = host.convert(input.bounds, from: input) + let composerView = try #require(composer) + let pane = host.convert(composerView.bounds, from: composerView) + #expect(pane.height + AgentComposerMetrics.splitTopInset >= AgentComposerMetrics.minimumHeight(hasFiles: !files.isEmpty) - 0.5) + #expect(frame.height > 0, "The actual text field must survive \(count) commands and \(files.count) attachments") + #expect(pane.insetBy(dx: -0.5, dy: -0.5).contains(frame), "The writing line must stay inside the sized pane") + let writingScroll = try #require(enclosingScroll(of: input)) + #expect(writingScroll.bounds.height >= AgentComposerMetrics.writingLineHeight - 0.5) + if count > 0 { + let list = try #require(commandScroll(in: host, excluding: input)) + let listFrame = host.convert(list.bounds, from: list) + #expect(listFrame.height >= 26, "Suggestions must retain a complete, selectable row") + #expect(host.bounds.insetBy(dx: -0.5, dy: -0.5).contains(listFrame), + "The floating list must stay inside its conversation scope") + let isAboveInput = host.isFlipped ? listFrame.maxY <= frame.minY + 0.5 + : listFrame.minY >= frame.maxY - 0.5 + #expect(isAboveInput, "The floating list must not cover the writing line") + } + } + } + } + } + } + @Test func toolSearchKeepsTheOriginalGroupWhenEvidenceMatches() throws { var first = AgentConversationMessage(id: "list", role: .tool, text: "List files") @@ -266,4 +488,27 @@ struct AgentConversationPresentationTests { if let handle = view as? SplitHandleInteractionView { return handle } return view.subviews.lazy.compactMap { splitHandle(in: $0) }.first } + + private func enclosingScroll(of view: NSView) -> NSScrollView? { + if let scroll = view.superview as? NSScrollView { return scroll } + return view.superview.flatMap { enclosingScroll(of: $0) } + } + + private func commandScroll(in view: NSView, excluding editor: NSView) -> NSScrollView? { + if let scroll = view as? NSScrollView, scroll.hasVerticalScroller, !editor.isDescendant(of: scroll) { return scroll } + return view.subviews.lazy.compactMap { commandScroll(in: $0, excluding: editor) }.first + } +} + +/// Observe the actual native editor frame assigned by SwiftUI, without a run loop delay. +private struct AgentDraftFrameProbe: NSViewRepresentable { + let onCreate: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView() + onCreate(view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} } diff --git a/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift new file mode 100644 index 000000000..d9a729026 --- /dev/null +++ b/macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift @@ -0,0 +1,195 @@ +import AppKit +import SwiftUI +import Testing +import LitheCoreContracts +@testable import Lithe +@testable import LitheAgentConversationModule + +@MainActor +@Suite("Thought transcript lifecycle", .serialized) +struct AgentThoughtTranscriptTests { + @Test + func manualExpansionSurvivesNonmatchingSearchRemovingAndRecreatingTheRow() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try record(host, name: "collapsed") + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "expanded thought") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try record(host, name: "expanded") + + host.rootView = transcript(feature, search: "no matching thought") + try await renderUntil(host, expected: "filtered row") { try snapshot(host) != expanded && snapshot(host) != collapsed } + try record(host, name: "filtered") + // Visiting a new empty session disposes the real scroll subtree while search remains active. + // Returning must retain this session's choice even after SwiftUI's lazy row cache is gone. + let filtered = try snapshot(host) + feature.selectSession("empty") + try await renderUntil(host, expected: "empty conversation") { try snapshot(host) != filtered } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "restored manual expansion") { try snapshot(host) == expanded } + try record(host, name: "restored") + } + } + + @Test + func matchingSearchRevealsACollapsedThoughtWithoutReplacingItsPreference() async throws { + try await withTranscript { feature, host, window in + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual expansion") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "manual collapse") { try snapshot(host) == collapsed } + + host.rootView = transcript(feature, search: "manifest") + try await renderUntil(host, expected: "matching thought text") { try snapshot(host) == expanded } + try pressThought(in: host, window: window) + renderFrame(host) + #expect(try snapshot(host) == expanded, "Searching must keep the matched reasoning visible after a click") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "original manual collapse") { try snapshot(host) == collapsed } + } + } + + @Test + func switchingSessionsKeepsIndependentPreferencesAndClosingResetsThem() async throws { + try await withTranscript { feature, host, window in + seedThought(in: feature, sessionID: "second") + renderFrame(host) + let collapsed = try snapshot(host) + try pressThought(in: host, window: window) + try await renderUntil(host, expected: "first session expanded") { try snapshot(host) != collapsed } + let expanded = try snapshot(host) + + feature.selectSession("second") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "second session default collapse") { try snapshot(host) == collapsed } + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "first session retained expansion") { try snapshot(host) == expanded } + + feature.closeConversation("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "remaining session") { try snapshot(host) == collapsed } + // Reopening the session with replayed messages must use the fresh row's default. + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + host.rootView = transcript(feature) + try await renderUntil(host, expected: "reopened session default") { try snapshot(host) == collapsed } + } + } + + private func withTranscript( + _ run: (AgentConnectionModel, NSHostingView, NSWindow) async throws -> Void + ) async throws { + let feature = AgentConnectionModel(transport: UnusedThoughtTransport()) + seedThought(in: feature, sessionID: "first") + feature.selectSession("first") + let host = NSHostingView(rootView: transcript(feature)) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 360, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + defer { window.contentView = nil; window.close() } + window.contentView = host + host.frame.size = NSSize(width: 360, height: 300) + window.orderFront(nil) + do { + try await run(feature, host, window) + await feature.stop() + } catch { + await feature.stop() + throw error + } + } + + private func transcript(_ feature: AgentConnectionModel, search: String = "") -> AnyView { + AnyView(AgentTranscriptView(feature: feature, agentName: "Fixture", agentVersion: nil, + agents: [], onSelectAgent: { _ in }, searchText: search) + .background(AgentPanelStyle.canvas).environment(\.colorScheme, .dark)) + } + + private func seedThought(in feature: AgentConnectionModel, sessionID: String) { + feature.receive(""" + {"kind":"update","sessionId":"\(sessionID)","update":{"sessionUpdate":"agent_thought_chunk",\ + "content":{"type":"text","text":"Find the manifest entry point"}}} + """) + feature.receive(""" + {"kind":"turnFinished","sessionId":"\(sessionID)"} + """) + } + + private func pressThought(in host: NSView, window: NSWindow) throws { + // The fixture's only thought is the first transcript row, inside the 12pt padding. + let point = NSPoint(x: 80, y: host.isFlipped ? 20 : host.bounds.height - 20) + let location = host.convert(point, to: nil) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + let event = try #require(NSEvent.mouseEvent(with: type, location: location, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, + clickCount: 1, pressure: type == .leftMouseDown ? 1 : 0)) + window.sendEvent(event) + } + } + + private func renderFrame(_ host: NSView) { + // Pump one native event without waiting: SwiftUI's lazy row disposal commits on the run loop. + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, 0, true) + host.layoutSubtreeIfNeeded() + host.displayIfNeeded() + CATransaction.flush() + } + + // Compare complete frames from this one host, without font- or OS-specific golden images. + private func snapshot(_ host: NSView) throws -> Data { + let bitmap = try renderedBitmap(host) + let pixels = try #require(bitmap.bitmapData) + return Data(bytes: pixels, count: bitmap.bytesPerRow * bitmap.pixelsHigh) + } + + private func renderedBitmap(_ host: NSView) throws -> NSBitmapImageRep { + host.displayIfNeeded() + CATransaction.flush() + let bitmap = try #require(NSBitmapImageRep(bitmapDataPlanes: nil, + pixelsWide: Int(host.bounds.width), pixelsHigh: Int(host.bounds.height), + bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false, + colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)) + let context = try #require(NSGraphicsContext(bitmapImageRep: bitmap)) + let layer = try #require(host.layer) + context.cgContext.translateBy(x: 0, y: host.bounds.height) + context.cgContext.scaleBy(x: 1, y: -1) + layer.render(in: context.cgContext) + return bitmap + } + + private func record(_ host: NSView, name: String) throws { + guard let directory = ProcessInfo.processInfo.environment["LITHE_THOUGHT_SCREENSHOTS"] else { return } + let bitmap = try renderedBitmap(host) + let data = try #require(bitmap.representation(using: .png, properties: [:])) + try data.write(to: URL(fileURLWithPath: directory).appendingPathComponent("\(name).png")) + } + + /// Wait for observable native rendering, with no fixed delay or private SwiftUI state access. + private func renderUntil(_ host: NSView, expected: String, condition: () throws -> Bool) async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while clock.now < deadline { + renderFrame(host) + if try condition() { return } + await Task.yield() + } + #expect(try condition(), "Did not render \(expected) within two seconds") + throw ThoughtRenderError.timeout + } +} + +private enum ThoughtRenderError: Error { case timeout } + +@MainActor +private struct UnusedThoughtTransport: AgentConversationTransport { + func open(configuration: AgentLaunchConfiguration, onEvent: @escaping @Sendable (String) -> Void) throws -> any AgentConnection { + throw AgentConversationError.notConnected + } +} diff --git a/rust/lithe-agent-host/src/tests.rs b/rust/lithe-agent-host/src/tests.rs index 12839266a..d014152ca 100644 --- a/rust/lithe-agent-host/src/tests.rs +++ b/rust/lithe-agent-host/src/tests.rs @@ -325,6 +325,10 @@ fn serialized_events_match_the_shared_fixture() { "toolCallUpdate", "sessionInfo", "usageUpdate", + "agentThoughtChunk", + "plan", + "availableCommands", + "currentModeUpdate", ] { let update = &events[name]["update"]; let parsed: agent_client_protocol::schema::v1::SessionUpdate = diff --git a/scripts/classify-ci-changes.sh b/scripts/classify-ci-changes.sh index 54a11d3ff..5339e48bd 100755 --- a/scripts/classify-ci-changes.sh +++ b/scripts/classify-ci-changes.sh @@ -389,7 +389,7 @@ while IFS=$'\t' read -r status first_path _; do rust_core=true macos_release=true ;; - scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) + scripts/build-windows.ps1|scripts/verify-windows-boundaries.ps1|scripts/verify-windows-boundaries.sh|scripts/prepare-jdtls.ps1|scripts/prepare-jdk.ps1|scripts/package-windows.ps1|scripts/install-windows-frontend-dependencies.ps1|scripts/invoke-windows-bun-install.ps1|scripts/windows-frontend-install.test.ts|scripts/invoke-windows-tauri-build.ps1|scripts/create-windows-updater-manifest.ps1|scripts/test-windows-updater-manifest.ps1) windows=true ;; scripts/prepare-lithe-pr-review.mjs|scripts/test-prepare-lithe-pr-review.mjs|scripts/run-lithe-codex-with-timeout.sh|scripts/update-repo-charts.py) diff --git a/scripts/install-windows-frontend-dependencies.ps1 b/scripts/install-windows-frontend-dependencies.ps1 index fed03f1cc..e5c256068 100644 --- a/scripts/install-windows-frontend-dependencies.ps1 +++ b/scripts/install-windows-frontend-dependencies.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([ValidateRange(1, 1200)][int]$InstallTimeoutSeconds = 300) $ErrorActionPreference = "Stop" $root = Split-Path -Parent $PSScriptRoot @@ -9,6 +9,27 @@ $expectedVersion = ([string]$package.packageManager) -replace '^bun@', '' $bunCache = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-cache")) $bunTemp = [System.IO.Path]::GetFullPath((Join-Path $root ".artifacts/bun-tmp")) $dependencyPaths = @("node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules") | ForEach-Object { Join-Path $root $_ } +$powerShell = (Get-Process -Id $PID).Path +$installWorker = Join-Path $PSScriptRoot "invoke-windows-bun-install.ps1" + +function Remove-InstallPath { + param([string]$Path) + + $deadline = [Diagnostics.Stopwatch]::StartNew() + while (Test-Path -LiteralPath $Path) { + try { + Remove-Item -Recurse -Force -LiteralPath $Path -ErrorAction Stop + return + } catch { + if ($deadline.Elapsed.TotalSeconds -ge 10) { + throw "Cannot clean installation data after releasing its process tree: $Path. $($_.Exception.Message)" + } + # Native termination and Windows filesystem filters can release + # locks just after the worker exits. This retry has a local deadline. + Start-Sleep -Milliseconds 100 + } + } +} function Write-CacheWarning { param([string]$Message, [string]$Title = "Bun cache fallback") @@ -44,7 +65,7 @@ $env:BUN_INSTALL_CACHE_DIR = $bunCache $env:BUN_TMPDIR = $bunTemp $env:BUN_FEATURE_FLAG_DISABLE_INSTALL_INDEX = "1" -if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } +Remove-InstallPath $bunTemp New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null if ($null -eq (Get-Command bun -ErrorAction SilentlyContinue)) { @@ -58,20 +79,21 @@ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $expectedVersion) { Push-Location $root try { - & bun install --frozen-lockfile + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { if ($env:LITHE_BUN_CACHE_VERIFIED -eq "true") { Write-CacheWarning "The verified Bun cache could not complete installation. Clearing it and retrying with ordinary downloads." } else { Write-CacheWarning "The initial Bun install failed. Clearing partial data and retrying with ordinary downloads." } - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } - if (Test-Path -LiteralPath $bunTemp) { Remove-Item -Recurse -Force -LiteralPath $bunTemp } + $env:LITHE_BUN_CACHE_VERIFIED = "false" + Remove-InstallPath $bunCache + Remove-InstallPath $bunTemp foreach ($dependencyPath in $dependencyPaths) { - if (Test-Path -LiteralPath $dependencyPath) { Remove-Item -Recurse -Force -LiteralPath $dependencyPath } + Remove-InstallPath $dependencyPath } New-Item -ItemType Directory -Force -Path $bunCache, $bunTemp | Out-Null - & bun install --frozen-lockfile --no-cache + & $powerShell -NoProfile -ExecutionPolicy Bypass -File $installWorker -NoCache -TimeoutSeconds $InstallTimeoutSeconds if ($LASTEXITCODE -ne 0) { throw "Windows frontend dependency installation failed after a clean retry." } @@ -95,7 +117,7 @@ try { } if (-not $cacheSealed) { Write-CacheWarning "The Bun download cache could not be sealed safely. Discarding it and continuing with the installed dependencies." - if (Test-Path -LiteralPath $bunCache) { Remove-Item -Recurse -Force -LiteralPath $bunCache } + Remove-InstallPath $bunCache if ($null -ne $env:GITHUB_ENV) { "LITHE_BUN_CACHE_VERIFIED=false" >> $env:GITHUB_ENV } } } diff --git a/scripts/invoke-windows-bun-install.ps1 b/scripts/invoke-windows-bun-install.ps1 new file mode 100644 index 000000000..af896ebcf --- /dev/null +++ b/scripts/invoke-windows-bun-install.ps1 @@ -0,0 +1,94 @@ +[CmdletBinding()] +param( + [switch]$NoCache, + [ValidateRange(1, 1200)][int]$TimeoutSeconds = 300 +) + +$ErrorActionPreference = "Stop" +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + throw "The Bun install worker requires Windows Job Objects." +} + +# Own the worker before Bun can spawn lifecycle scripts. The non-inheritable +# handle belongs to this worker process; Windows closes it at process exit and +# kills every remaining descendant, even when Bun has already exited on error. +# Do not dispose the handle in-process: the worker itself is a job member. +Add-Type -TypeDefinition @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Threading; + +public static class LitheBunInstallJob { + static Timer deadline; + const uint KillOnJobClose = 0x2000; + const int ExtendedLimitInformation = 9; + + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits { + public long PerProcessUserTime, PerJobUserTime; + public uint LimitFlags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + struct IoCounters { + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + } + + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr securityAttributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits limits, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll")] + static extern IntPtr GetCurrentProcess(); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + public static IntPtr AttachWorker() { + IntPtr job = CreateJobObject(IntPtr.Zero, null); + if (job == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); + var limits = new ExtendedLimits(); + limits.Basic.LimitFlags = KillOnJobClose; + if (!SetInformationJobObject(job, ExtendedLimitInformation, ref limits, (uint)Marshal.SizeOf(limits)) || + !AssignProcessToJobObject(job, GetCurrentProcess())) { + int error = Marshal.GetLastWin32Error(); + CloseHandle(job); + throw new Win32Exception(error, "Cannot own the Bun installation process tree."); + } + return job; + } + + public static void StartDeadline(int seconds) { + deadline = new Timer(_ => { + Console.Error.WriteLine("Bun installation exceeded its " + seconds + " second deadline."); + Environment.Exit(124); + }, null, seconds * 1000, Timeout.Infinite); + } + + public static void FinishDeadline() { deadline.Dispose(); } +} +'@ + +$installJob = [LitheBunInstallJob]::AttachWorker() +[LitheBunInstallJob]::StartDeadline($TimeoutSeconds) +$arguments = @("install", "--frozen-lockfile") +if ($NoCache) { $arguments += "--no-cache" } +& bun @arguments +$installExitCode = $LASTEXITCODE +[LitheBunInstallJob]::FinishDeadline() +[GC]::KeepAlive($installJob) +exit $installExitCode diff --git a/scripts/reuse-worktree-resources.mjs b/scripts/reuse-worktree-resources.mjs index 69d5700a1..b8910eda3 100644 --- a/scripts/reuse-worktree-resources.mjs +++ b/scripts/reuse-worktree-resources.mjs @@ -169,6 +169,8 @@ async function validatorArguments(resource, cachePath, targetRoot) { ]; } if (resource.validator === "bun") { + // This route validates sealed downloads only. Job-owned installation temp + // data and node_modules stay in the registry's non-reusable exclusions. const packagePath = await requiredFile(targetRoot, "package.json", resource.id); const packageManifest = JSON.parse(await fs.readFile(packagePath, "utf8")); const match = String(packageManifest.packageManager ?? "").match(/^bun@(.+)$/); diff --git a/scripts/test-reuse-worktree-resources.mjs b/scripts/test-reuse-worktree-resources.mjs index 77a2cbbde..d7366e43f 100644 --- a/scripts/test-reuse-worktree-resources.mjs +++ b/scripts/test-reuse-worktree-resources.mjs @@ -64,6 +64,14 @@ function reuse(extraArguments = []) { } try { + await test("frontend install temp data and dependencies cannot cross worktrees", { timeout: 15000 }, () => { + const listed = run(process.execPath, [reuseScript, "--list"]); + assertSucceeded(listed); + assert.ok(!listed.stdout.includes("frontend-install-state")); + const refused = reuse(["--resource", "frontend-install-state"]); + assert.notEqual(refused.status, 0); + assert.match(diagnostics(refused), /frontend-install-state.*node_modules.*bun-tmp.*cannot be reused/); + }); await test("Java launch files are never listed or copied between worktrees", { timeout: 15000 }, () => { const listed = run(process.execPath, [reuseScript, "--list"]); assertSucceeded(listed); diff --git a/scripts/windows-frontend-install.test.ts b/scripts/windows-frontend-install.test.ts new file mode 100644 index 000000000..5717cbc03 --- /dev/null +++ b/scripts/windows-frontend-install.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, test } from "bun:test"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { runProcess } from "../.agents/skills/write-stable-tests/scripts/test-timing-lib.mjs"; + +const scripts = path.dirname(fileURLToPath(import.meta.url)); +const dependencyPaths = ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules"]; + +// A real child process keeps its current directory locked on Windows. IPC +// confirms that lock before the fake installer exits, without sleeps or polling. +const fakeBun = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const { spawn } = require("node:child_process"); +const net = require("node:net"); +const root = process.env.LITHE_INSTALL_FIXTURE_ROOT; +if (process.argv.includes("--locker")) { + const server = net.createServer(); + server.listen(0, "127.0.0.1", () => process.send("locked")); +} else if (process.argv.includes("--version")) { + console.log("1.3.12"); +} else { + const retry = process.argv.includes("--no-cache"); + const mode = process.env.LITHE_INSTALL_FIXTURE_MODE; + fs.appendFileSync(path.join(root, "attempts.jsonl"), JSON.stringify(process.argv.slice(2)) + "\n"); + const directories = ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules"]; + if (retry && mode !== "permanent-failure" && mode !== "timeout") { + for (const directory of directories) { + if (fs.existsSync(path.join(root, directory, "partial"))) throw Error("Partial dependencies survived cleanup"); + fs.mkdirSync(path.join(root, directory), { recursive: true }); + } + fs.writeFileSync(path.join(root, "retry-succeeded"), "yes"); + } else { + for (const directory of directories) { + fs.mkdirSync(path.join(root, directory, "partial"), { recursive: true }); + } + const locked = path.join(root, "node_modules", "partial"); + const child = spawn(process.execPath, [__filename, "--locker"], { + cwd: locked, stdio: ["ignore", "ignore", "ignore", "ipc"] + }); + child.once("error", error => { throw error; }); + child.once("message", () => { + fs.appendFileSync(path.join(root, "owned-pids"), child.pid + "\n"); + if (mode !== "timeout") process.exit(mode === "success-with-child" ? 0 : 1); + }); + } +} +`; + +function isRunning(pid: number) { + try { process.kill(pid, 0); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +async function ownedPids(root: string) { + try { + return (await fs.readFile(path.join(root, "owned-pids"), "utf8")).trim().split(/\s+/).map(Number); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; + throw error; + } +} + +async function withInstaller(mode: string, check: (root: string, result: Awaited>) => Promise, timeoutSeconds = 300) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "lithe Windows install ")); + try { + await fs.mkdir(path.join(root, "scripts")); + await fs.mkdir(path.join(root, "windows/tauri"), { recursive: true }); + await fs.mkdir(path.join(root, "bin")); + await fs.mkdir(path.join(root, ".artifacts/bun-cache"), { recursive: true }); + for (const name of ["install-windows-frontend-dependencies.ps1", "invoke-windows-bun-install.ps1"]) { + await fs.copyFile(path.join(scripts, name), path.join(root, "scripts", name)); + } + await fs.writeFile(path.join(root, "windows/tauri/package.json"), JSON.stringify({ packageManager: "bun@1.3.12" })); + await fs.writeFile(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "old manifest"); + await fs.writeFile(path.join(root, ".artifacts/bun-cache/old-cache"), "old"); + await fs.writeFile(path.join(root, "bin/fake-bun.cjs"), fakeBun); + await fs.writeFile(path.join(root, "bin/bun.cmd"), '@echo off\r\nnode "%~dp0fake-bun.cjs" %*\r\n'); + // Cache validation is covered separately; here the sealer records whether + // the fallback invalidated its verified flag and allowed a new manifest. + await fs.writeFile(path.join(root, "scripts/verify-download-cache.mjs"), ` + import { writeFileSync } from "node:fs"; + import path from "node:path"; + const root = process.env.LITHE_INSTALL_FIXTURE_ROOT; + writeFileSync(path.join(root, "sealed-state"), process.env.LITHE_BUN_CACHE_VERIFIED); + writeFileSync(path.join(root, ".artifacts/bun-cache/.lithe-integrity.json"), "new manifest"); + `); + const result = await runProcess({ + command: "pwsh", + args: ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", path.join(root, "scripts/install-windows-frontend-dependencies.ps1"), + "-InstallTimeoutSeconds", String(timeoutSeconds)], + cwd: root, + env: { + ...process.env, + PATH: path.join(root, "bin") + path.delimiter + process.env.PATH, + LITHE_INSTALL_FIXTURE_ROOT: root, + LITHE_INSTALL_FIXTURE_MODE: mode, + LITHE_BUN_CACHE_VERIFIED: "true", + GITHUB_ACTIONS: "false", + GITHUB_ENV: "", + }, + timeoutMs: 20000, + }); + expect(result.timedOut, result.stdout + result.stderr).toBe(false); + await check(root, result); + expect((await ownedPids(root)).filter(isRunning)).toEqual([]); + } finally { + // Cleanup remains owned even if a worker or assertion fails. + for (const pid of await ownedPids(root)) { + if (isRunning(pid)) { + await runProcess({ command: "taskkill.exe", args: ["/PID", String(pid), "/T", "/F"], timeoutMs: 5000 }); + } + } + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } +} + +describe.skipIf(process.platform !== "win32")("Windows dependency install lifecycle", () => { + test("failed installation releases locked descendants before its clean retry", async () => { + await withInstaller("recover", async (root, result) => { + expect(result.code, result.stdout + result.stderr).toBe(0); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n").map(line => JSON.parse(line)); + expect(attempts).toEqual([["install", "--frozen-lockfile"], ["install", "--frozen-lockfile", "--no-cache"]]); + expect(await fs.readFile(path.join(root, "retry-succeeded"), "utf8")).toBe("yes"); + expect(await fs.readFile(path.join(root, "sealed-state"), "utf8")).toBe("false"); + for (const directory of dependencyPaths) expect(await fs.readdir(path.join(root, directory))).toEqual([]); + expect((await ownedPids(root)).length).toBe(1); + }); + }); + + test("permanent failure reports the retry error and releases both process trees", async () => { + await withInstaller("permanent-failure", async (root, result) => { + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("failed after a clean retry"); + expect((await ownedPids(root)).length).toBe(2); + }); + }); + + test("successful installation releases leftover children without clearing a valid cache", async () => { + await withInstaller("success-with-child", async (root, result) => { + expect(result.code, result.stdout + result.stderr).toBe(0); + expect(await fs.readFile(path.join(root, ".artifacts/bun-cache/old-cache"), "utf8")).toBe("old"); + expect((await ownedPids(root)).length).toBe(1); + const attempts = (await fs.readFile(path.join(root, "attempts.jsonl"), "utf8")).trim().split("\n"); + expect(attempts.length).toBe(1); + }); + }); + + test("a stalled installer reaches its local deadline and releases its descendants", async () => { + await withInstaller("timeout", async (root, result) => { + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("exceeded its 3 second deadline"); + expect(result.stderr).toContain("failed after a clean retry"); + expect((await ownedPids(root)).length).toBe(2); + }, 3); + }); +}); diff --git a/scripts/worktree-resources.json b/scripts/worktree-resources.json index 0e25cd98c..37aead5e2 100644 --- a/scripts/worktree-resources.json +++ b/scripts/worktree-resources.json @@ -28,6 +28,13 @@ } ], "excludedResources": [ + { + "id": "frontend-install-state", + "reusable": false, + "locations": ["node_modules", "windows/tauri/node_modules", "frontend/editor/node_modules", ".artifacts/bun-tmp"], + "identity": "Mutable installation state tied to one worktree, OS, architecture, Bun version and lifecycle attempt; no sealed reusable identity stamp", + "reason": "Each Windows install attempt owns its lifecycle processes in a worker Job Object. Release that tree before clearing partial dependencies or retrying. Never copy install temp data or node_modules across worktrees; only the sealed Bun download cache may be reused." + }, { "id": "jdt-maven-settings", "reusable": false, diff --git a/shared/fixtures/agent/acp-events-v1.json b/shared/fixtures/agent/acp-events-v1.json index 7cb57fbf6..70f7b2670 100644 --- a/shared/fixtures/agent/acp-events-v1.json +++ b/shared/fixtures/agent/acp-events-v1.json @@ -56,6 +56,26 @@ "sessionId": "session-1", "update": { "sessionUpdate": "agent_message_chunk", "content": { "type": "text", "text": "This project **builds** an IDE." } } }, + "agentThoughtChunk": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "agent_thought_chunk", "content": { "type": "text", "text": "Read the manifest first." } } + }, + "plan": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "plan", "entries": [{ "content": "Read the manifest", "priority": "high", "status": "completed" }, { "content": "Run the tests", "priority": "medium", "status": "in_progress" }, { "content": "Summarize the results", "priority": "low", "status": "pending" }] } + }, + "availableCommands": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "available_commands_update", "availableCommands": [{ "name": "review", "description": "Review the current changes", "input": { "hint": "optional focus" } }, { "name": "compact", "description": "Summarize the conversation to free context" }] } + }, + "currentModeUpdate": { + "kind": "update", + "sessionId": "session-1", + "update": { "sessionUpdate": "current_mode_update", "currentModeId": "auto" } + }, "toolCall": { "kind": "update", "sessionId": "session-1", diff --git a/shared/platform-feature-matrix.json b/shared/platform-feature-matrix.json index a3102611d..cb9c74d2c 100644 --- a/shared/platform-feature-matrix.json +++ b/shared/platform-feature-matrix.json @@ -420,6 +420,114 @@ "owner": "Agent", "verification": "macOS:让真实 Agent 连续列文件、读取文件、执行命令和编辑,确认相邻工具调用只显示一张默认折叠的时间线卡片;展开后每条状态、完整输入、输出和文件位置仍可查看,进行中、全部完成、失败和中断时汇总正确。用 Agent 文字说明和新一轮用户消息隔开工具调用,确认保留文字顺序且不会跨边界合并;搜索工具输入、输出或文件路径时自动展开匹配组;覆盖文件路径仅由 diff 内容上报、未出现在工具标题、输入输出或 locations 中的情况,历史恢复结果与实时流一致。在窄宽面板及深浅主题检查长标题截断、悬停全文、列表滚动和详情可读性。Windows Agent 对话 UI 尚未实现。" }, + { + "id": "agent-reasoning-display", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 上报的思考内容(ACP agent_thought_chunk)显示为独立的可折叠思考块,流式输出时展开、回复或工具到达后收起,不并入回复正文,导出 Markdown 时以引用块保留", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentThoughtTranscriptTests.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json", + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentTranscriptView.swift", + "macos/Sources/Lithe/Views/Agent/AgentTurnStatisticsView.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:使用会上报思考内容的 Agent 与思考强度发送需要推理的问题,确认思考块在流式输出时展开显示“思考中…”,底部等待行显示“回复中…”并保留本轮计时,回复或工具调用到达后自动收起为“思考过程”,手动展开后保持;搜索不匹配的文字使思考行消失后,清空搜索仍恢复手动展开状态,切换会话不串用偏好、关闭会话后释放偏好;思考与回复交替时顺序正确且文本不混合;手动收起思考块后搜索其内容,命中时仍展开,搜索期间保持可见,清空搜索后恢复手动折叠状态;加载历史会话后思考块与实时流一致;导出 Markdown 中思考以引用块出现,历史消息数不计思考。在窄宽面板及深浅主题检查长文本换行与可读性。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-plan-display", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 上报的执行计划(ACP plan)固定显示在活动统计栏上方,折叠时显示完成进度与当前步骤,展开查看每条状态;每次更新整体替换,空计划清除", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:让真实 Agent(例如在计划模式或多步任务中)上报计划,确认计划栏显示“计划 已完成/总数”与当前步骤,展开后每条待办、进行中、已完成状态和图标正确;步骤推进时整体刷新而不重复;切换会话显示各自计划,加载历史会话后计划恢复;不上报计划的 Agent 不显示计划栏。在窄宽面板及深浅主题检查长步骤截断与滚动。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-slash-commands", + "area": "AI", + "group": "Agent 对话", + "capability": "输入框以 / 开头(或以 $ 开头选择 Skill)时,按 Agent 为当前会话上报的命令列表(ACP available_commands_update)弹出补全,显示名称、说明与参数提示,选择后填入命令并按普通消息原样发送;长补全列表保留可见输入行,最小输入区中在上方浮出完整列表", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentSessionGuidance.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerView.swift", + "macos/Sources/Lithe/Views/Agent/AgentSessionGuidanceViews.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerDraftArea.swift", + "macos/Tests/LitheTests/AgentConversationPresentationTests.swift", + "macos/Sources/Lithe/Views/Agent/AgentComposerContent.swift", + "macos/Sources/Lithe/Views/Agent/AgentCommandCompletion.swift", + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:分别连接 Codex 与 Claude,输入 / 确认弹出的命令与 Agent 实际上报一致,不出现 Lithe 内置命令;Codex 上报的 $Skill 补全为 $名称 而不是 /$名称,输入 $ 只列 Skill;继续输入时按前缀优先过滤,无匹配时显示“没有匹配的命令”,输入空格后关闭;鼠标选择或 Return 补全为“/命令 ”,完整命令按 Return 直接发送并由 Agent 执行;macOS 14 及以上方向键和 Tab 可导航,macOS 13 仍可鼠标选择;Esc 先关闭补全、回复中再次 Esc 才停止;切换会话后显示各自命令列表;上游重复上报同名命令时只显示首条定义,断连后清空旧补全,重连加载后以新进程上报为准;在默认和最小输入区高度、有无附带文件、窄宽面板中输入 /,用长列表和空结果确认输入行可见可编辑,浮出的命令可鼠标选择,方向键选中最后一条时列表跟随滚动。Windows Agent 对话 UI 尚未实现。" + }, + { + "id": "agent-mode-sync", + "area": "AI", + "group": "Agent 对话", + "capability": "Agent 自行切换模式(ACP current_mode_update)时同步当前会话的权限模式选择器,仅接受上游配置中存在的模式,不回发设置请求", + "macos": { + "evidence": [ + "macos/Sources/LitheAgentConversationModule/Application/AgentConnectionModel.swift", + "macos/Tests/LitheTests/AgentConversationFeatureModelTests.swift", + "shared/fixtures/agent/acp-events-v1.json" + ], + "implementationStatus": "implemented", + "verificationStatus": "pending" + }, + "windows": { + "evidence": [ + "windows/tauri/src/features" + ], + "implementationStatus": "missing", + "verificationStatus": "not-applicable" + }, + "owner": "Agent", + "verification": "macOS:使用真实 Agent 进入并退出计划模式,确认 current_mode_update 只同步当前会话中包含该模式 ID 的 mode 配置,模型与思考选项不改变,不向 Agent 回发 setConfigOption;未知模式 ID 不改变选择器且不显示原始 ID。切换会话确认各自模式独立,历史加载后以 Agent 回放为准。Windows Agent 对话 UI 尚未实现。" + }, { "id": "agent-turn-statistics", "area": "AI",