From 9e8323dc976501e2d3fbd8e9614e67e10de966fe Mon Sep 17 00:00:00 2001 From: coso Date: Tue, 8 Sep 2026 21:46:53 +0800 Subject: [PATCH] Release v1.142.0 --- .gitignore | 2 + RELEASE_NOTES.en.md | 41 +- RELEASE_NOTES.md | 41 +- electron/electronRuntime.ts | 1 + electron/hostCommands.test.ts | 79 + electron/hostCommands.ts | 37 + electron/ipcChannels.ts | 3 + electron/main.ts | 6 + electron/secureCredentialStore.test.ts | 155 ++ electron/secureCredentialStore.ts | 276 ++ internal/aiprompts/architecture.md | 72 +- internal/aiprompts/commands.md | 31 +- internal/exec-plans/README.md | 4 +- .../codex-alignment-v1-coordination-plan.md | 78 + .../codex-desktop-complete-alignment-plan.md | 1 + .../codex-lime-tui-cli-difference-report.md | 366 +++ internal/exec-plans/release-v1.142.0-plan.md | 92 + .../tui-cli-codex-sync-next-plan.md | 263 ++ internal/exec-plans/tui-cli-surfaces-plan.md | 532 +++- .../exec-plans/tui-structure-inventory.json | 2469 ++++++++++++++--- internal/roadmap/coding/README.md | 53 +- .../roadmap/coding/implementation-plan.md | 22 +- .../roadmap/coding/runtime-capability-map.md | 4 +- .../roadmap/coding/upstream-gap-review.md | 31 +- lime-rs/Cargo.lock | 98 +- lime-rs/Cargo.toml | 6 +- .../tool_executor/orchestration.rs | 2 + lime-rs/crates/app-server-client/src/lib.rs | 11 +- .../crates/app-server-client/src/session.rs | 132 +- .../app-server/src/execution_process.rs | 209 +- .../app-server/src/execution_process/tests.rs | 244 +- lime-rs/crates/app-server/src/main.rs | 60 +- .../app-server/src/runtime/session_shell.rs | 149 +- .../src/runtime/tests/session_shell.rs | 20 +- .../thread_item_projection/coding_items.rs | 13 + .../src/runtime_backend/coding_events.rs | 383 ++- .../runtime_backend/coding_events/tests.rs | 219 +- lime-rs/crates/cli/src/debug_sandbox.rs | 22 +- lime-rs/crates/cli/src/sandbox_setup.rs | 6 +- .../tool-runtime/src/execution_process.rs | 220 +- .../src/execution_process/output_buffer.rs | 286 ++ .../tool-runtime/src/execution_process/pty.rs | 14 +- .../src/execution_process/tests.rs | 82 +- .../src/execution_process/windows.rs | 2 +- .../windows_runner_supervisor.rs | 18 +- lime-rs/crates/tool-runtime/src/tool_call.rs | 4 + .../crates/tool-runtime/src/tool_executor.rs | 44 +- .../crates/tool-runtime/src/tool_lifecycle.rs | 88 + .../crates/tool-runtime/src/unified_exec.rs | 113 +- .../tool-runtime/src/unified_exec/tests.rs | 110 +- .../tests/windows_restricted_execution.rs | 19 +- lime-rs/crates/tui/Cargo.toml | 16 +- lime-rs/crates/tui/src/app.rs | 891 ++---- .../crates/tui/src/app/agent_navigation.rs | 344 +++ lime-rs/crates/tui/src/app/agent_picker.rs | 279 ++ lime-rs/crates/tui/src/app/agents_overview.rs | 327 +++ .../tui/src/app/agents_overview_render.rs | 322 +++ .../tui/src/app/agents_overview_tests.rs | 164 ++ .../tui/src/app/agents_overview_threads.rs | 205 ++ .../tui/src/app/agents_overview_view.rs | 624 +++++ .../tui/src/app/app_server_event_targets.rs | 233 ++ .../crates/tui/src/app/app_server_events.rs | 63 + .../crates/tui/src/app/app_server_requests.rs | 68 + lime-rs/crates/tui/src/app/event_dispatch.rs | 193 ++ lime-rs/crates/tui/src/app/input.rs | 144 + .../tui/src/app/pending_interactive_replay.rs | 750 +++++ lime-rs/crates/tui/src/app/reconnect.rs | 96 + lime-rs/crates/tui/src/app/replay_filter.rs | 181 ++ .../crates/tui/src/app/session_lifecycle.rs | 59 + lime-rs/crates/tui/src/app/tests.rs | 864 ++++++ .../crates/tui/src/app/thread_event_buffer.rs | 99 + .../tui/src/app/thread_event_buffer_tests.rs | 144 + lime-rs/crates/tui/src/app/thread_events.rs | 293 ++ lime-rs/crates/tui/src/app/thread_settings.rs | 111 + lime-rs/crates/tui/src/app_server_session.rs | 190 +- .../tui/src/bottom_pane/approval_overlay.rs | 2 +- .../tui/src/bottom_pane/chat_composer.rs | 281 +- .../chat_composer/attachment_state.rs | 66 + .../bottom_pane/chat_composer/draft_state.rs | 18 + .../chat_composer/history_search.rs | 103 + .../bottom_pane/chat_composer/reconnect.rs | 33 + .../chat_composer/reconnect_tests.rs | 61 + lime-rs/crates/tui/src/bottom_pane/mod.rs | 52 +- lime-rs/crates/tui/src/bottom_pane/render.rs | 2 +- .../src/bottom_pane/request_user_input/mod.rs | 2 +- .../bottom_pane/request_user_input/render.rs | 2 +- .../crates/tui/src/bottom_pane/textarea.rs | 617 ++++ .../tui/src/bottom_pane/textarea/wrapping.rs | 201 ++ .../bottom_pane/textarea/wrapping_tests.rs | 30 + lime-rs/crates/tui/src/clipboard_copy.rs | 12 +- lime-rs/crates/tui/src/collaboration_modes.rs | 139 + lime-rs/crates/tui/src/command_popup.rs | 14 +- lime-rs/crates/tui/src/diff_render.rs | 54 +- lime-rs/crates/tui/src/entry.rs | 54 +- lime-rs/crates/tui/src/external_editor.rs | 12 +- lime-rs/crates/tui/src/highlight.rs | 20 +- lime-rs/crates/tui/src/key_hint.rs | 42 + lime-rs/crates/tui/src/keymap.rs | 54 + lime-rs/crates/tui/src/lib.rs | 8 +- lime-rs/crates/tui/src/locale.rs | 428 +++ lime-rs/crates/tui/src/markdown.rs | 32 +- lime-rs/crates/tui/src/markdown/table.rs | 2 +- lime-rs/crates/tui/src/model_catalog.rs | 75 + lime-rs/crates/tui/src/model_picker.rs | 6 +- lime-rs/crates/tui/src/multi_agents.rs | 466 ++++ lime-rs/crates/tui/src/pager_overlay.rs | 4 +- .../crates/tui/src/pending_input_preview.rs | 16 +- lime-rs/crates/tui/src/projection.rs | 122 +- lime-rs/crates/tui/src/projection_tests.rs | 73 + lime-rs/crates/tui/src/reconnect.rs | 87 +- lime-rs/crates/tui/src/resume_picker.rs | 687 ++++- .../crates/tui/src/resume_picker/archive.rs | 17 +- .../tui/src/resume_picker/archive_tests.rs | 8 +- lime-rs/crates/tui/src/runtime.rs | 687 +++-- lime-rs/crates/tui/src/runtime_pty_tests.rs | 430 ++- lime-rs/crates/tui/src/settings.rs | 20 +- lime-rs/crates/tui/src/slash_command.rs | 26 +- lime-rs/crates/tui/src/status_indicator.rs | 4 +- .../crates/tui/src/status_indicator_widget.rs | 2 +- .../tui/src/terminal_hyperlinks/paragraph.rs | 2 +- lime-rs/crates/tui/src/thread_transcript.rs | 4 +- lime-rs/crates/tui/src/tui.rs | 175 +- lime-rs/crates/tui/src/tui/event_stream.rs | 8 +- lime-rs/crates/tui/src/view.rs | 205 +- lime-rs/crates/tui/src/viewport.rs | 201 ++ lime-rs/crates/tui/src/wrapping.rs | 16 +- package.json | 2 +- packages/app-server-client/README.md | 29 +- packages/app-server-client/package.json | 3 + packages/app-server-client/src/index.ts | 1 + packages/app-server-client/src/remote.ts | 559 ++++ .../app-server-client/src/sidecar-manifest.ts | 40 +- packages/app-server-client/src/ws.d.ts | 42 + .../tests/browser-entry.test.mjs | 1 + .../app-server-client/tests/client.test.mjs | 17 + .../app-server-client/tests/remote.test.mjs | 263 ++ packages/app-server-client/tsconfig.json | 2 +- packages/cli/package.json | 2 +- pnpm-lock.yaml | 6 +- scripts/app-server/cli-gate-b.mjs | 4 +- scripts/app-server/cli-gate-b.test.mjs | 2 + scripts/app-server/terminal-gate-binaries.mjs | 67 + .../terminal-gate-binaries.test.mjs | 42 + scripts/app-server/terminal-gate-fixture.mjs | 11 +- scripts/app-server/tui-gate-b.mjs | 53 +- scripts/app-server/tui-gate-b.test.mjs | 15 +- .../app-server/tui-structure-inventory.mjs | 10 +- .../tui-structure-inventory.test.mjs | 298 +- scripts/check-app-server-client-contract.mjs | 1 + scripts/harness/deepswe-adapter-core.mjs | 47 + scripts/harness/deepswe-adapter.test.mjs | 55 +- .../api/oemCloudControlPlane.contract.test.ts | 36 + .../oemCloudControlPlaneBootstrapParsers.ts | 2 + .../api/oemCloudControlPlaneCoreParsers.ts | 58 +- src/lib/api/oemCloudControlPlaneTypes.ts | 11 + 155 files changed, 18544 insertions(+), 2710 deletions(-) create mode 100644 electron/secureCredentialStore.test.ts create mode 100644 electron/secureCredentialStore.ts create mode 100644 internal/exec-plans/codex-lime-tui-cli-difference-report.md create mode 100644 internal/exec-plans/release-v1.142.0-plan.md create mode 100644 internal/exec-plans/tui-cli-codex-sync-next-plan.md create mode 100644 lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs create mode 100644 lime-rs/crates/tui/src/app/agent_navigation.rs create mode 100644 lime-rs/crates/tui/src/app/agent_picker.rs create mode 100644 lime-rs/crates/tui/src/app/agents_overview.rs create mode 100644 lime-rs/crates/tui/src/app/agents_overview_render.rs create mode 100644 lime-rs/crates/tui/src/app/agents_overview_tests.rs create mode 100644 lime-rs/crates/tui/src/app/agents_overview_threads.rs create mode 100644 lime-rs/crates/tui/src/app/agents_overview_view.rs create mode 100644 lime-rs/crates/tui/src/app/app_server_event_targets.rs create mode 100644 lime-rs/crates/tui/src/app/app_server_events.rs create mode 100644 lime-rs/crates/tui/src/app/app_server_requests.rs create mode 100644 lime-rs/crates/tui/src/app/event_dispatch.rs create mode 100644 lime-rs/crates/tui/src/app/input.rs create mode 100644 lime-rs/crates/tui/src/app/pending_interactive_replay.rs create mode 100644 lime-rs/crates/tui/src/app/reconnect.rs create mode 100644 lime-rs/crates/tui/src/app/replay_filter.rs create mode 100644 lime-rs/crates/tui/src/app/session_lifecycle.rs create mode 100644 lime-rs/crates/tui/src/app/tests.rs create mode 100644 lime-rs/crates/tui/src/app/thread_event_buffer.rs create mode 100644 lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs create mode 100644 lime-rs/crates/tui/src/app/thread_events.rs create mode 100644 lime-rs/crates/tui/src/app/thread_settings.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/chat_composer/attachment_state.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/chat_composer/draft_state.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/chat_composer/history_search.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect_tests.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/textarea.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/textarea/wrapping.rs create mode 100644 lime-rs/crates/tui/src/bottom_pane/textarea/wrapping_tests.rs create mode 100644 lime-rs/crates/tui/src/collaboration_modes.rs create mode 100644 lime-rs/crates/tui/src/key_hint.rs create mode 100644 lime-rs/crates/tui/src/keymap.rs create mode 100644 lime-rs/crates/tui/src/model_catalog.rs create mode 100644 lime-rs/crates/tui/src/multi_agents.rs create mode 100644 lime-rs/crates/tui/src/viewport.rs create mode 100644 packages/app-server-client/src/remote.ts create mode 100644 packages/app-server-client/src/ws.d.ts create mode 100644 packages/app-server-client/tests/remote.test.mjs create mode 100644 scripts/app-server/terminal-gate-binaries.mjs create mode 100644 scripts/app-server/terminal-gate-binaries.test.mjs diff --git a/.gitignore b/.gitignore index ce9e9b46e..ff5b143a4 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,8 @@ internal/exec-plans/* !internal/exec-plans/tui-codex-snapshot-inventory.json !internal/exec-plans/tui-codex-snapshot-inventory.md !internal/exec-plans/tui-cli-surfaces-plan.md +!internal/exec-plans/codex-lime-tui-cli-difference-report.md +!internal/exec-plans/tui-cli-codex-sync-next-plan.md !internal/exec-plans/cli-codex-test-inventory.json !internal/exec-plans/cli-structure-inventory.json !internal/exec-plans/tui-structure-inventory.json diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md index 40d1663a1..e1016f6cd 100644 --- a/RELEASE_NOTES.en.md +++ b/RELEASE_NOTES.en.md @@ -1,41 +1,42 @@ -## Lime v1.141.0 +## Lime v1.142.0 Simplified Chinese release notes are the primary version. ### New Features -- Added dedicated Code Mode protocol, runtime, host, and session-facade crates for stdio/gRPC, V8 execution, typed content, cancellation, reconnect, and session-lease lifecycles. -- Extended the shared App Server CLI/TUI surface with thread resume and management, MCP/Skills/Plugin queries, model and permission controls, JSON/JSONL, prompt history, approvals, request-user-input, and queued-input editing. -- Added authenticated remote WebSocket session transport with `ws/wss`, Bearer tokens, protocol identity checks, ping/pong, and fail-closed policy; CLI/TUI reuse the same session facade without duplicating runtime or persistence. -- Added Codex-aligned TUI Markdown, diff, syntax highlighting, OSC 8 links, width-aware tables, clipboard/image paste, slash-command popup, static pager, transcript overlay, and queued follow-up preview. +- Continued Codex-shaped current owners across TUI/CLI: multi-agent navigation and overview, model catalog, collaboration modes, paginated resume sessions and transcript pager, composer textarea, history search, viewport handling, and five-language terminal copy. +- Added unified App Server execution-process lifecycle and command-event mirroring for canonical Thread/Turn/Item command-start, live-output, completion, and test-run projection. +- Added an authenticated WebSocket foundation to `@limecloud/app-server-client` for the same JSON-RPC session, with `ws/wss`, Bearer token, tenant headers, identity/protocol handshake, ping/pong, and message limits. +- Added Electron `safeStorage`-backed Cloud session credential storage with set/status/delete IPC; status exposes metadata only and never reads back tokens. ### Fixes -- Tightened the App Server `command/exec` permission boundary to reject client-supplied grants and preserve fail-closed typed lowering. -- Fixed Code Mode host/client disconnect, duplicate execution, stale-cell, pending-callback, and session-close cleanup so old-generation state cannot leak into reconnects. -- Fixed CLI/TUI terminal lifecycle, external-editor handoff, Unicode cursor, narrow-terminal truncation, queue recovery, and failed-terminal projection boundaries. -- Fixed regressions in file-system watching, Agent Runtime typed content, MCP notification projection, and tool-lifecycle summaries. +- Unified bounded output capture across pipe, PTY, Windows restricted runner, remote Environment, and unified exec with stable head/tail retention, UTF-8 boundaries, and omission accounting. +- Fixed App Server process-owner connection isolation, duplicate active `processId` rejection, disconnect cleanup, stdin/resize/terminate lifecycle, and per-process output replay. +- Fixed TUI terminal restoration, disconnected editing, Unicode cursor behavior, narrow-terminal wrapping, queue recovery, history hydration, approval/input requests, and multi-agent projection boundaries. +- Hardened sidecar `dataDir` and Cloud App Server endpoint validation against `undefined`/relative paths, URL credentials, query/fragment leakage, and insecure public WebSocket token connections. ### Improvements and Refactoring -- Migrated Code Mode out of the old `tool-runtime` embedded process/V8 implementation; production calls now use the four current crates, with only explicit compatibility exports left at the old boundary. -- Converged CLI Rust command owners and Codex-shaped modules, and aligned npm root/platform launchers, native payload staging, signal forwarding, and release ordering under `packages/cli`. -- Migrated TUI interaction, rendering, and terminal algorithms by snapshot-inventory classification while keeping canonical Thread/Turn/Item as the only session source of truth. -- Added CLI/TUI/Code Mode, remote transport, Windows restricted-execution, Electron release-workflow, and governance guards; Cloud remains only an authenticated-transport extension point, not a production path. +- Moved TUI aggregate state into unique `app/`, `chat_composer/`, `textarea/`, and `viewport` owners while retaining the App Server canonical projection as the sole session source of truth. +- Reworked App Server and tool-runtime execution output around per-process bounded queues and a shared framer, removing global FIFO, tail-only capture, and lossy per-chunk UTF-8 semantics. +- Added isolated Docker CLI configuration and active Docker-context endpoint injection to the DeepSWE harness. +- Kept Cloud transport foundation-only: credentials, tenant isolation, reconnect, rate limits, audit, and a production endpoint remain Cloud-service prerequisites. ### Testing and Quality -- Added Code Mode protocol/runtime/host/facade lifecycle, typed-content, gRPC, reconnect, and cancellation tests, plus App Server permission-boundary regressions. -- Expanded CLI Gate B, TUI Gate B, real stdio/PTY, npm packaged launcher, remote-auth, snapshot-inventory, terminal-rendering, and cross-platform release guards. -- Kept five-language copy, protocol generation, script/CLI boundaries, Electron Forge, version consistency, and legacy-governance checks in the release gate. +- `tui` Rust unit tests: 390/390 passed; `@limecloud/app-server-client`: 11 test files and 139 tests passed. +- CLI Gate B, TUI Gate B, Agent Runtime current Electron fixtures, App Server client contracts, structure inventory, harness, legacy governance, and script-boundary checks passed. +- Preserved five-language copy, generated protocol, Electron Forge, version-consistency, real PTY/stdio, and canonical Thread/Turn/Item identity gates. ### Documentation -- Updated architecture, command, governance, quality-workflow, and CLI/TUI/Code Mode execution-plan documentation to record the single Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item path and the Cloud transport boundary. +- Updated architecture, command, quality, roadmap, and TUI/CLI execution-plan documentation for execution processes, remote transport, credential storage, and the single Product Surface -> App Server JSON-RPC -> RuntimeCore path. ### Other -- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.141.0`. -- Excluded local SQLite/WAL, runtime database, and `.DS_Store` artifacts under `undefined/` from this release candidate; the old Code Mode implementation is `dead/deleted`, with no parallel runtime, compat, or deprecated owner added. +- Bumped the root app, CLI npm package, Rust workspace, and Cargo.lock versions to `1.142.0`. +- The local `rusty_v8 v150.4.0` Darwin/aarch64 prebuilt archive returned 404, so the V8-dependent workspace full build was not completed locally; Windows hardware matrix, signing/notarization, and full Forge artifacts remain platform-runner evidence. +- Excluded the untracked local Mach-O `rust_out` and ignored local build directories from the release candidate; the existing v1.141.0 tag was not deleted or overwritten. -**Full changes**: `v1.140.0` -> `v1.141.0` +**Full changes**: `v1.141.0` -> `v1.142.0` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index efa9a117a..907be35c4 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,39 +1,40 @@ -## Lime v1.141.0 +## Lime v1.142.0 ### 新功能 -- 新增独立 Code Mode protocol、runtime、host 与 session facade crate,统一承载 stdio/gRPC、V8 执行、typed content、取消、重连和 session lease 生命周期。 -- CLI/TUI 继续扩展为统一 App Server 产品面:支持 Thread 恢复与管理、MCP/Skills/Plugin 查询、模型与权限控制、JSON/JSONL、prompt history、审批、request_user_input 和队列输入编辑。 -- 新增远程 WebSocket session transport,支持 `ws/wss`、Bearer token、协议身份校验、ping/pong 与 fail-closed 认证策略;CLI/TUI 复用同一 session facade,不复制 runtime 或持久化。 -- TUI 新增 Codex 对齐的 Markdown、Diff、语法高亮、OSC 8 链接、宽度感知表格、剪贴板/图片粘贴、slash command popup、static pager、transcript overlay 和 queued follow-up 预览。 +- TUI/CLI 继续按 Codex 形状拆分 current owner:多 Agent 导航与概览、模型目录、协作模式、恢复会话分页与 transcript pager、composer textarea、历史搜索、viewport 和五语言终端文案。 +- App Server 增加统一执行进程生命周期与 command event mirror,向 canonical Thread/Turn/Item 投影 command started、实时输出、完成状态和测试运行信息。 +- `@limecloud/app-server-client` 提供同一 JSON-RPC session 的认证 WebSocket foundation,支持 `ws/wss`、Bearer token、租户 header、身份/协议握手、ping/pong 和消息大小上限。 +- Electron 增加基于 `safeStorage` 的 Cloud session credential store 与 set/status/delete IPC;状态只返回 metadata,不提供 token 读取。 ### 修复 -- 收紧 App Server `command/exec` 权限边界,拒绝客户端注入的授权字段并保持 typed lowering fail-closed。 -- 修复 Code Mode host/client 的断线、重复 execution、stale cell、pending callback 和 session close 清理,避免旧代际状态泄漏到重连会话。 -- 修复 CLI/TUI 终端生命周期、external editor、Unicode 光标、窄终端截断、队列恢复与失败终态投影边界。 -- 修复文件系统 watch、Agent Runtime typed content、MCP 通知投影和工具生命周期摘要的回归断言。 +- 统一 pipe、PTY、Windows restricted runner、远程 Environment 和 unified exec 的有界输出捕获,保留稳定 head/tail、UTF-8 边界和 omission 统计,避免大输出阻塞或跨进程串流。 +- 修复 App Server process owner 的连接隔离、重复 active `processId`、断连清理、stdin/resize/terminate 生命周期和逐进程 output replay。 +- 修复 TUI 终端恢复、断线编辑、Unicode 光标、窄终端换行、队列恢复、历史 hydration、审批/输入请求和多 Agent 状态投影边界。 +- 收紧 sidecar `dataDir` 与 Cloud App Server endpoint 校验,拒绝 `undefined`/相对路径、URL 凭证、query/fragment 和不安全的公开 WebSocket token 连接。 ### 优化与重构 -- 将 Code Mode 旧 `tool-runtime` 内嵌 process/V8 实现物理迁出并删除,生产调用统一切换到四层 current crate;旧路径仅保留显式兼容导出。 -- CLI Rust 目录与命令 owner 按 Codex 形状收敛,npm 根包/平台包 launcher、native payload、signal forwarding 与发布顺序统一到 `packages/cli`。 -- TUI 交互、渲染和终端算法按 snapshot inventory 分层迁移,保持 canonical Thread/Turn/Item 为唯一会话事实源。 -- 补充 CLI/TUI/Code Mode、远程 transport、Windows restricted execution、Electron release workflow 和治理边界守卫;Cloud 仅保留经认证 transport 的扩展点,不进入生产路径。 +- 将 TUI 聚合状态迁入 `app/`、`chat_composer/`、`textarea/`、`viewport` 等唯一 current owner,继续以 App Server canonical projection 为会话事实源。 +- App Server 与 tool-runtime 的执行输出改用 per-process bounded queue 和统一 framer,删除全局 FIFO、tail-only capture 与逐 chunk lossy UTF-8 语义。 +- DeepSWE harness 支持隔离 Docker CLI 配置和当前 Docker context endpoint 注入,避免本地 daemon 配置污染 verifier。 +- Cloud transport 保持 foundation-only:凭证、租户隔离、重连、限流、审计和生产 endpoint 仍需 Cloud 服务完成后才能启用。 ### 测试与质量 -- 新增 Code Mode protocol/runtime/host/facade 生命周期、typed content、gRPC、重连与取消测试,并补齐 App Server 权限边界回归。 -- 扩展 CLI Gate B、TUI Gate B、真实 stdio/PTY、npm packaged launcher、远程认证、snapshot inventory、终端渲染和跨平台发布守卫。 -- 保持五语言文案、协议生成、脚本/CLI 边界、Electron Forge、版本一致性和 legacy 治理检查为发布门禁。 +- `tui` Rust 单元测试 390/390 通过;`@limecloud/app-server-client` 11 个测试文件、139 项通过。 +- CLI Gate B、TUI Gate B、Agent Runtime current Electron fixture、App Server client contracts、结构 inventory、harness、legacy governance 和脚本边界检查通过。 +- 保持五语言文案、协议生成、Electron Forge、版本一致性、真实 PTY/stdio 和 canonical Thread/Turn/Item identity 为发布门禁。 ### 文档 -- 更新架构、命令、治理、质量工作流与 CLI/TUI、Code Mode 执行计划,记录 Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item 的唯一业务主链及 Cloud transport 边界。 +- 更新架构、命令、质量、路线图与 TUI/CLI 执行计划,记录执行进程、远程 transport、凭证存储和唯一 Product Surface -> App Server JSON-RPC -> RuntimeCore 主链边界。 ### 其他 -- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.141.0`。 -- 本次发布候选排除 `undefined/` 下本地 SQLite/WAL、runtime 数据库和 `.DS_Store` 运行产物;Code Mode 旧物理实现归 `dead/deleted`,不新增平行 runtime、compat 或 deprecated owner。 +- 将根应用、CLI npm 包、Rust workspace 与 Cargo.lock 版本统一提升到 `1.142.0`。 +- 本地 `rusty_v8 v150.4.0` Darwin/aarch64 预构建 archive 返回 404,包含 V8 的 workspace 完整编译未在本机完成;Windows 真机矩阵、签名/公证和完整 Forge 产物留给对应平台 runner。 +- 发布候选排除未跟踪的本机 Mach-O `rust_out` 与被忽略的本地生成目录;不删除、不覆盖既有 v1.141.0 tag。 -**完整变更**: `v1.140.0` -> `v1.141.0` +**完整变更**: `v1.141.0` -> `v1.142.0` diff --git a/electron/electronRuntime.ts b/electron/electronRuntime.ts index cf78a5f51..ec1e6ec12 100644 --- a/electron/electronRuntime.ts +++ b/electron/electronRuntime.ts @@ -23,6 +23,7 @@ export const { shell, systemPreferences, protocol, + safeStorage, Tray, WebContentsView, } = electron; diff --git a/electron/hostCommands.test.ts b/electron/hostCommands.test.ts index dab90186d..c7582aa32 100644 --- a/electron/hostCommands.test.ts +++ b/electron/hostCommands.test.ts @@ -9,6 +9,7 @@ import { ElectronHostCommands } from "./hostCommands"; import type { ElectronAppServerHost } from "./appServerHost"; import { SystemUtilityHost } from "./systemUtilityHost"; import { VoiceModelHost } from "./voiceModelHost"; +import type { SecureCredentialStore } from "./secureCredentialStore"; const { browserWindowCtorMock, @@ -233,6 +234,7 @@ function createHost( throw new Error("App Server should not be called"); }, appDataRoot = userDataDir, + secureCredentialStore: SecureCredentialStore | null = null, ) { const appServerHost = { request, @@ -242,6 +244,7 @@ function createHost( userDataDir, emit, appDataRoot, + secureCredentialStore, ); } @@ -281,6 +284,82 @@ async function withRemotePngServer( } } +describe("ElectronHostCommands secure Cloud credential boundary", () => { + it("只允许写入和读取 metadata,不把 token 回传 renderer", async () => { + const userDataDir = await createTempUserDataDir(); + const store: SecureCredentialStore = { + setCloudSessionCredential: vi.fn(async (credential) => ({ + available: true, + exists: true, + tenantId: credential.tenantId, + endpoint: credential.endpoint, + updatedAt: "2026-09-06T00:00:00.000Z", + })), + readCloudSessionCredential: vi.fn(async () => ({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + })), + getCloudSessionCredentialMetadata: vi.fn(async () => ({ + available: true, + exists: true, + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + updatedAt: "2026-09-06T00:00:00.000Z", + })), + deleteCloudSessionCredential: vi.fn(async () => ({ deleted: true })), + }; + const host = createHost( + userDataDir, + () => undefined, + undefined, + userDataDir, + store, + ); + + await expect( + host.invoke("cloud_session_credential_set", { + request: { + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + }, + }), + ).resolves.toEqual({ + available: true, + exists: true, + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + updatedAt: "2026-09-06T00:00:00.000Z", + }); + await expect( + host.invoke("cloud_session_credential_status"), + ).resolves.toEqual({ + available: true, + exists: true, + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + updatedAt: "2026-09-06T00:00:00.000Z", + }); + await expect( + host.invoke("cloud_session_credential_delete"), + ).resolves.toEqual({ deleted: true }); + expect( + JSON.stringify(store.getCloudSessionCredentialMetadata), + ).not.toContain("session-secret"); + expect(store.readCloudSessionCredential).not.toHaveBeenCalled(); + }); + + it("未配置 secure store 时 fail closed", async () => { + const userDataDir = await createTempUserDataDir(); + const host = createHost(userDataDir); + + await expect( + host.invoke("cloud_session_credential_status"), + ).rejects.toThrow("secure credential storage is not configured"); + }); +}); + afterEach(async () => { vi.useRealTimers(); vi.clearAllMocks(); diff --git a/electron/hostCommands.ts b/electron/hostCommands.ts index bdd012972..9e2f004e1 100644 --- a/electron/hostCommands.ts +++ b/electron/hostCommands.ts @@ -40,6 +40,10 @@ import { LayeredDesignProjectHost } from "./layeredDesignProjectHost"; import { openResourceManagerWindow } from "./resourceManagerWindowHost"; import { SystemUtilityHost } from "./systemUtilityHost"; import { VoiceModelHost } from "./voiceModelHost"; +import type { + CloudSessionCredential, + SecureCredentialStore, +} from "./secureCredentialStore"; type HostArgs = Record | null | undefined; type AppServerParams = Record; @@ -79,6 +83,7 @@ export class ElectronHostCommands { readonly #layeredDesignProjectHost = new LayeredDesignProjectHost(); readonly #systemUtilityHost: SystemUtilityHost; readonly #voiceModelHost: VoiceModelHost; + readonly #secureCredentialStore: SecureCredentialStore | null; #oauthCallbackBridgeServer: Server | null = null; #oauthCallbackBridgeTimer: ReturnType | null = null; @@ -87,10 +92,12 @@ export class ElectronHostCommands { userDataDir = app.getPath("userData"), emit: HostEventEmitter = () => undefined, appDataRoot = resolveCurrentDesktopStorageRoots(userDataDir).appDataRoot, + secureCredentialStore: SecureCredentialStore | null = null, ) { this.#appServerHost = appServerHost; this.#userDataDir = userDataDir; this.#emit = emit; + this.#secureCredentialStore = secureCredentialStore; this.#systemUtilityHost = new SystemUtilityHost({ appDataRoot, readConfig: () => this.#readConfig(), @@ -192,6 +199,12 @@ export class ElectronHostCommands { return null; case "app_server_host_diagnostics": return this.#appServerHost.getDiagnostics(); + case "cloud_session_credential_set": + return await this.#setCloudSessionCredential(args); + case "cloud_session_credential_status": + return await this.#requireSecureCredentialStore().getCloudSessionCredentialMetadata(); + case "cloud_session_credential_delete": + return await this.#requireSecureCredentialStore().deleteCloudSessionCredential(); case "report_frontend_crash": this.#reportFrontendCrash(args); return { success: true }; @@ -570,6 +583,30 @@ export class ElectronHostCommands { report, ); } + + async #setCloudSessionCredential(args: HostArgs): Promise { + const request = readRequest(args); + const credential: CloudSessionCredential = { + tenantId: + readString(request, "tenantId") ?? + readString(request, "tenant_id") ?? + readRequiredString(request, "tenantId"), + endpoint: + readString(request, "endpoint") ?? + readRequiredString(request, "endpoint"), + token: readRequiredRawString(request, "token"), + }; + return await this.#requireSecureCredentialStore().setCloudSessionCredential( + credential, + ); + } + + #requireSecureCredentialStore(): SecureCredentialStore { + if (!this.#secureCredentialStore) { + throw new Error("secure credential storage is not configured"); + } + return this.#secureCredentialStore; + } } function readRecord( diff --git a/electron/ipcChannels.ts b/electron/ipcChannels.ts index 330ec868a..23de87eb2 100644 --- a/electron/ipcChannels.ts +++ b/electron/ipcChannels.ts @@ -13,6 +13,9 @@ export const ELECTRON_HOST_COMMANDS = [ "app_server_handle_json_lines", "app_server_drain_events", "app_server_host_diagnostics", + "cloud_session_credential_set", + "cloud_session_credential_status", + "cloud_session_credential_delete", "get_runtime_provider_selection", "sync_tray_model_shortcuts", "take_pending_skill_package_open_requests", diff --git a/electron/main.ts b/electron/main.ts index cebc62f74..f5f8e4768 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -26,6 +26,7 @@ import { import { ElectronBrowserTabHost, isBrowserTabCommand } from "./browserTabHost"; import { ElectronDevHttpBridge } from "./devHttpBridge"; import { ElectronHostCommands } from "./hostCommands"; +import { ElectronSecureCredentialStore } from "./secureCredentialStore"; import { buildMainWindowChromeOptions, buildMainWindowStartupHtml, @@ -57,6 +58,7 @@ import { powerMonitor, screen, session, + safeStorage, type MenuItemConstructorOptions, type OpenDialogOptions, protocol, @@ -144,6 +146,10 @@ const hostCommands = new ElectronHostCommands( app.getPath("userData"), broadcast, desktopStorageRoots.appDataRoot, + new ElectronSecureCredentialStore( + desktopStorageRoots.appDataRoot, + safeStorage, + ), ); const updateHost = new ElectronUpdateHost(broadcast, { open: openUpdateNotificationWindow, diff --git a/electron/secureCredentialStore.test.ts b/electron/secureCredentialStore.test.ts new file mode 100644 index 000000000..b26026ea2 --- /dev/null +++ b/electron/secureCredentialStore.test.ts @@ -0,0 +1,155 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + ElectronSecureCredentialStore, + type SafeStorageLike, +} from "./secureCredentialStore"; + +const tempRoots: string[] = []; + +function createSafeStorage(available = true): SafeStorageLike { + return { + isEncryptionAvailable: () => available, + encryptString: (plainText) => + Buffer.from(Buffer.from(plainText, "utf8").toString("base64url"), "utf8"), + decryptString: (encrypted) => + Buffer.from(encrypted.toString("utf8"), "base64url").toString("utf8"), + }; +} + +async function createRoot(): Promise { + const root = await mkdtemp( + path.join(os.tmpdir(), "lime-secure-credentials-"), + ); + tempRoots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all( + tempRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("ElectronSecureCredentialStore", () => { + it("加密保存 Cloud session,并只返回不含 token 的 metadata", async () => { + const root = await createRoot(); + const store = new ElectronSecureCredentialStore(root, createSafeStorage()); + + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + }), + ).resolves.toMatchObject({ + available: true, + exists: true, + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + }); + + const stored = await readFile( + path.join(root, "credentials", "app-server-session.v1"), + "utf8", + ); + expect(stored).not.toContain("session-secret"); + await expect( + store.getCloudSessionCredentialMetadata(), + ).resolves.toMatchObject({ + available: true, + exists: true, + tenantId: "tenant-001", + }); + await expect(store.readCloudSessionCredential()).resolves.toEqual({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + }); + }); + + it("拒绝不安全 endpoint、空 token 和明文 ws 公网地址", async () => { + const root = await createRoot(); + const store = new ElectronSecureCredentialStore(root, createSafeStorage()); + + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "https://gateway.example.test/v1/app-server", + token: "session-secret", + }), + ).rejects.toThrow("endpoint must use ws:// or wss://"); + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server?token=secret", + token: "session-secret", + }), + ).rejects.toThrow("endpoint must not contain userinfo, query, or fragment"); + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "ws://gateway.example.test/v1/app-server", + token: "session-secret", + }), + ).rejects.toThrow("insecure ws:// endpoints must be loopback URLs"); + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: " ", + }), + ).rejects.toThrow("token must not be empty"); + }); + + it("加密能力不可用时拒绝写入,但删除仍可幂等执行", async () => { + const root = await createRoot(); + const store = new ElectronSecureCredentialStore( + root, + createSafeStorage(false), + ); + + await expect( + store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + }), + ).rejects.toThrow("secure credential storage is unavailable"); + await expect(store.getCloudSessionCredentialMetadata()).resolves.toEqual({ + available: false, + exists: false, + }); + await expect(store.deleteCloudSessionCredential()).resolves.toEqual({ + deleted: false, + }); + }); + + it("损坏的密文 fail closed,删除后回到空状态", async () => { + const root = await createRoot(); + const filePath = path.join(root, "credentials", "app-server-session.v1"); + const store = new ElectronSecureCredentialStore(root, createSafeStorage()); + await store.setCloudSessionCredential({ + tenantId: "tenant-001", + endpoint: "wss://gateway.example.test/v1/app-server", + token: "session-secret", + }); + await writeFile(filePath, "corrupt", "utf8"); + + await expect(store.readCloudSessionCredential()).rejects.toThrow( + "stored Cloud session credential is invalid", + ); + await expect(store.deleteCloudSessionCredential()).resolves.toEqual({ + deleted: true, + }); + await expect(store.getCloudSessionCredentialMetadata()).resolves.toEqual({ + available: true, + exists: false, + }); + }); +}); diff --git a/electron/secureCredentialStore.ts b/electron/secureCredentialStore.ts new file mode 100644 index 000000000..9d8a4bc84 --- /dev/null +++ b/electron/secureCredentialStore.ts @@ -0,0 +1,276 @@ +import { + access, + mkdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; +import { isIP } from "node:net"; + +const CREDENTIALS_DIRECTORY = "credentials"; +const CREDENTIAL_FILE_NAME = "app-server-session.v1"; +const CREDENTIAL_VERSION = 1; +const MAX_TENANT_ID_LENGTH = 256; +const MAX_ENDPOINT_LENGTH = 2_048; +const MAX_TOKEN_LENGTH = 64 * 1024; + +export type CloudSessionCredential = { + tenantId: string; + endpoint: string; + token: string; +}; + +export type CloudSessionCredentialMetadata = { + available: boolean; + exists: boolean; + tenantId?: string; + endpoint?: string; + updatedAt?: string; +}; + +export interface SecureCredentialStore { + setCloudSessionCredential( + credential: CloudSessionCredential, + ): Promise; + readCloudSessionCredential(): Promise; + getCloudSessionCredentialMetadata(): Promise; + deleteCloudSessionCredential(): Promise<{ deleted: boolean }>; +} + +export interface SafeStorageLike { + isEncryptionAvailable(): boolean; + encryptString(plainText: string): Buffer; + decryptString(encrypted: Buffer): string; +} + +type CredentialEnvelope = { + version: typeof CREDENTIAL_VERSION; + credential: CloudSessionCredential; + updatedAt: string; +}; + +export class ElectronSecureCredentialStore implements SecureCredentialStore { + readonly #filePath: string; + readonly #safeStorage: SafeStorageLike; + + constructor(appDataRoot: string, safeStorage: SafeStorageLike) { + this.#filePath = path.join( + path.resolve(appDataRoot), + CREDENTIALS_DIRECTORY, + CREDENTIAL_FILE_NAME, + ); + this.#safeStorage = safeStorage; + } + + async setCloudSessionCredential( + credential: CloudSessionCredential, + ): Promise { + const normalized = normalizeCredential(credential); + this.#assertEncryptionAvailable(); + + const envelope: CredentialEnvelope = { + version: CREDENTIAL_VERSION, + credential: normalized, + updatedAt: new Date().toISOString(), + }; + const encrypted = this.#safeStorage.encryptString(JSON.stringify(envelope)); + const directory = path.dirname(this.#filePath); + await mkdir(directory, { recursive: true, mode: 0o700 }); + + const tempPath = `${this.#filePath}.${process.pid}.${Date.now()}.tmp`; + let moved = false; + try { + await writeFile(tempPath, encrypted, { mode: 0o600 }); + try { + await rename(tempPath, this.#filePath); + } catch (error) { + const code = + error && typeof error === "object" + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code !== "EEXIST" && code !== "EPERM") { + throw error; + } + await rm(this.#filePath, { force: true }); + await rename(tempPath, this.#filePath); + } + moved = true; + } finally { + if (!moved) { + await rm(tempPath, { force: true }).catch(() => undefined); + } + } + + return metadataFromEnvelope(envelope, true); + } + + async readCloudSessionCredential(): Promise { + const envelope = await this.#readEnvelope(); + return envelope?.credential ?? null; + } + + async getCloudSessionCredentialMetadata(): Promise { + const exists = await fileExists(this.#filePath); + const available = this.#isEncryptionAvailable(); + if (!exists || !available) { + return { available, exists }; + } + + const envelope = await this.#readEnvelope(); + return envelope + ? metadataFromEnvelope(envelope, true) + : { available, exists: false }; + } + + async deleteCloudSessionCredential(): Promise<{ deleted: boolean }> { + const existed = await fileExists(this.#filePath); + await rm(this.#filePath, { force: true }); + return { deleted: existed }; + } + + async #readEnvelope(): Promise { + if (!(await fileExists(this.#filePath))) { + return null; + } + this.#assertEncryptionAvailable(); + + try { + const encrypted = await readFile(this.#filePath); + const decoded = JSON.parse( + this.#safeStorage.decryptString(encrypted), + ) as unknown; + return parseEnvelope(decoded); + } catch { + throw new Error("stored Cloud session credential is invalid"); + } + } + + #isEncryptionAvailable(): boolean { + try { + return this.#safeStorage.isEncryptionAvailable(); + } catch { + return false; + } + } + + #assertEncryptionAvailable(): void { + if (!this.#isEncryptionAvailable()) { + throw new Error("secure credential storage is unavailable"); + } + } +} + +function normalizeCredential( + credential: CloudSessionCredential, +): CloudSessionCredential { + const tenantId = normalizeField( + credential.tenantId, + "tenantId", + MAX_TENANT_ID_LENGTH, + ); + const endpoint = normalizeEndpoint(credential.endpoint); + const token = normalizeField(credential.token, "token", MAX_TOKEN_LENGTH); + return { tenantId, endpoint, token }; +} + +function normalizeField( + value: unknown, + name: string, + maxLength: number, +): string { + if (typeof value !== "string") { + throw new Error(`${name} must be a string`); + } + const normalized = value.trim(); + if (!normalized) { + throw new Error(`${name} must not be empty`); + } + if (normalized.length > maxLength) { + throw new Error(`${name} exceeds the maximum length`); + } + if (/[\u0000-\u001f\u007f]/u.test(normalized)) { + throw new Error(`${name} contains control characters`); + } + return normalized; +} + +function normalizeEndpoint(value: unknown): string { + const endpoint = normalizeField(value, "endpoint", MAX_ENDPOINT_LENGTH); + let url: URL; + try { + url = new URL(endpoint); + } catch { + throw new Error("endpoint must be a valid WebSocket URL"); + } + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error("endpoint must use ws:// or wss://"); + } + if (!url.hostname || url.username || url.password || url.search || url.hash) { + throw new Error("endpoint must not contain userinfo, query, or fragment"); + } + if (url.protocol === "ws:" && !isLoopbackUrl(url)) { + throw new Error("insecure ws:// endpoints must be loopback URLs"); + } + return url.toString(); +} + +function isLoopbackUrl(url: URL): boolean { + const hostname = url.hostname.toLowerCase(); + return ( + hostname === "localhost" || + hostname === "[::1]" || + (isIP(hostname) === 4 && hostname.startsWith("127.")) || + (isIP(hostname) === 6 && hostname === "::1") + ); +} + +function parseEnvelope(value: unknown): CredentialEnvelope { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("invalid credential envelope"); + } + const record = value as Record; + if ( + record.version !== CREDENTIAL_VERSION || + typeof record.updatedAt !== "string" + ) { + throw new Error("invalid credential envelope"); + } + const credential = record.credential; + if ( + !credential || + typeof credential !== "object" || + Array.isArray(credential) + ) { + throw new Error("invalid credential envelope"); + } + const normalized = normalizeCredential(credential as CloudSessionCredential); + return { + version: CREDENTIAL_VERSION, + credential: normalized, + updatedAt: record.updatedAt, + }; +} + +function metadataFromEnvelope( + envelope: CredentialEnvelope, + available: boolean, +): CloudSessionCredentialMetadata { + return { + available, + exists: true, + tenantId: envelope.credential.tenantId, + endpoint: envelope.credential.endpoint, + updatedAt: envelope.updatedAt, + }; +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} diff --git a/internal/aiprompts/architecture.md b/internal/aiprompts/architecture.md index bad47e1ee..dc5c507ed 100644 --- a/internal/aiprompts/architecture.md +++ b/internal/aiprompts/architecture.md @@ -51,9 +51,9 @@ Future Cloud -> authenticated transport ----> LimeCore gateway -> ThreadStore + Thread/Turn/Item projection ``` -`Product Surface`、`Host/Transport` 与业务 runtime 是三层边界。Desktop、TUI、CLI 可以拥有各自的窗口/终端生命周期、输入法、快捷键和展示投影,但不能拥有 provider loop、工具 registry、approval authority、Thread/Turn/Item 状态机或持久化副本。`app-server-protocol` 是跨 surface 合同,`app-server-client` 是 Rust surface 的连接/session owner;本地 stdio 是当前实现,未来 Cloud 通过同一 session facade 接入认证后的远端 transport。TUI 的 `resume` picker 只读取 App Server `thread/list`,选中后进入标准 `thread/resume`;连接中断只由 TUI session owner 做 bounded reconnect,并以原 Thread id hydrate canonical history,composer draft 保持在 surface 内存中,旧 connection 的 pending approval 丢弃且不得跨连接回放。TUI 的 effort/permission 快捷键只 lowering 到 `thread/settings/update`;`/model` picker 只消费 typed `model/list` 的可见 catalog,并在选择后 lowering 到同一 settings method,不复制 Codex 配置或 provider catalog。 +`Product Surface`、`Host/Transport` 与业务 runtime 是三层边界。Desktop、TUI、CLI 可以拥有各自的窗口/终端生命周期、输入法、快捷键和展示投影,但不能拥有 provider loop、工具 registry、approval authority、Thread/Turn/Item 状态机或持久化副本。`app-server-protocol` 是跨 surface 合同,`app-server-client` 是 Rust surface 的连接/session owner;本地 stdio 是当前实现,未来 Cloud 通过同一 session facade 接入认证后的远端 transport。TUI 的 `resume` picker 与 Codex-shaped `Agents Overview` 只读取 App Server `thread/list`,选中后进入标准 `thread/resume`;Overview 的状态分组、子线程状态冒泡与刷新仅投影 canonical Thread/notification,不访问私有 daemon/state DB。Overview 的新任务、改名和停止操作分别 lowering 到现有 `thread/start` + `turn/start`、`thread/name/set` 与 `turn/interrupt`,不复制 Codex agents daemon;刷新请求使用单一 request identity,合并 pending 请求并重放刷新期间到达的最新 thread notification。TUI 在 `app/app_server_event_targets.rs` 复用 Codex 同名 `server_notification_thread_target`、`server_request_thread_id` 与 `ServerNotificationThreadTarget`:Thread-scoped notification 可以更新 Overview/导航,但只有当前 Thread 能写入当前 `ConversationProjection`;foreign Thread 的可交互 server request 进入对应 `ThreadEventStore` 的 bounded replay channel,`pending_interactive_replay` 只允许仍未解决的 request 在 `thread/resume` 后重放;Dynamic Tool 与 MCP elicitation 当前没有 TUI consumer,立即 fail closed,buffer 满时也直接 reject,不得落入当前 Thread 的 BottomPane。连接中断只由 TUI `app/reconnect.rs` session owner 做 bounded reconnect,并以原 Thread id hydrate canonical history,composer draft 保持在 surface 内存中,旧 connection 的 pending approval 丢弃且不得跨连接回放。TUI 的 `app/app_server_events.rs` 只负责 transport event 分流和 notification dispatch;`app/app_server_requests.rs` 负责 reverse server request routing 与 reject/queue 语义;`app/thread_events.rs` 负责 canonical Thread/Turn/Item notification projection 和 agent liveness;`app/pending_interactive_replay.rs` 负责 Codex 对齐的 pending request identity;`app/thread_settings.rs` 负责当前 thread 的 model/provider、effort、permission profile 与 collaboration mode read model;`app/event_dispatch.rs` 的 `App::handle_event` 负责 App Server-backed settings、协作模式、权限和 Agents Overview action。TUI 的 effort/permission 快捷键只 lowering 到 `thread/settings/update`;`/model` picker 只消费 typed `model/list` 的可见 catalog,并在选择后 lowering 到同一 settings method,不复制 Codex 配置或 provider catalog。 -TUI 的终端输入与绘制调度 owner 对齐 Codex `tui`:`tui::EventBroker` 统一持有可暂停/恢复的 crossterm 输入源,`tui::TuiEventStream` 将 key、paste、resize、focus 和 draw 归一化后交给 runtime;`tui::FrameRequester` 与 `frame_rate_limiter` 合并异步重绘并限制频率。`TerminalGuard` 只负责 terminal mode 生命周期,并在外部编辑器或恢复流程中暂停 broker,确保 stdin 不被后台 reader 占用。该层不得承接 App Server 请求、Thread 状态或第二套业务事件总线。 +TUI 的终端输入与绘制调度 owner 对齐 Codex `tui`:`tui::EventBroker` 统一持有可暂停/恢复的 crossterm 输入源,`tui::TuiEventStream` 将 key、paste、resize、focus 和 draw 归一化后交给 runtime;`tui::FrameRequester` 与 `frame_rate_limiter` 合并异步重绘并限制频率。workspace 级 crossterm 固定使用 Codex 同源的 `openai-oss-forks/crossterm` revision `45fecb9508105988f42fe6ff0441783ed3717f92`,其 terminal readiness 和外部消费输入修复是 external editor 交接的唯一依赖事实源。`tui::Tui` 只负责 terminal mode 生命周期,并在外部编辑器或恢复流程中暂停 broker,确保 stdin 不被后台 reader 占用。该层不得承接 App Server 请求、Thread 状态或第二套业务事件总线。真实 TUI Gate B 使用 PTY 驱动键盘和 alternate screen,并按 Codex 测试依赖使用 `vt100::Parser` 还原关闭前的实际屏幕;不能用删除 ANSI 后的字节拼接冒充用户可见状态。 CLI 的 npm 分发边界对齐 `/Users/coso/Documents/dev/rust/codex/codex-cli`:`@limecloud/lime` 根包只发布 ESM launcher,并通过 optional dependency alias 选择平台包;平台包在 `vendor//bin` 原子携带 `lime`、`app-server`、`code-mode-host`、Windows sandbox helpers 与 App Server 所需动态运行库。launcher 只负责平台解析、包管理器归属、参数/stdin/stdout 转发、signal forwarding 和退出原因镜像,不下载 release asset、不回退 `cargo run`,也不承接 App Server 业务。平台包必须先于根包串行发布,避免根包引用尚不存在的载荷版本;尚无真实构建/运行证据的平台不进入 optional dependency catalog。 @@ -113,6 +113,7 @@ Renderer 可以临时保存输入框、选择态、展开态和 optimistic UI; | `electron/preload.ts` | `contextBridge`、最小暴露面和 IPC 调用入口。 | | `electron/ipcChannels.ts` | IPC channel 常量与协议白名单。 | | `electron/*Host.ts` | 一个桌面能力一个 owner,例如 App Server sidecar、文件/项目壳、窗口、通知、更新、浏览器、语音。 | +| `electron/secureCredentialStore.ts` | 使用 Electron `safeStorage` 保存受管 Cloud session credential;只向 IPC 投影存在性和 metadata。 | | `electron/desktopResourceReadiness.ts` | 运行时读取并校验 macOS/Windows desktop resource manifest 的身份与必需资源存在性;不替代发布 verifier。 | | `electron/appServerHost.ts` | sidecar 生命周期与 `app_server_handle_json_lines` 的宿主边界。 | | `electron/forge/`、`forge.config.mjs` | Forge 打包、maker、签名和 release 事实源。 | @@ -121,6 +122,12 @@ Electron 负责窗口、托盘、Dock、系统文件选择、权限、外部链 新增 Electron 命令前先判断是否只是已有 `app_server_handle_json_lines` 的转发。只有系统宿主能力才新增 IPC;业务能力一律优先新增 App Server JSON-RPC。 +Cloud session credential 是 Desktop Host 的平台能力,不是 Renderer 或 App Server 的第二份 +业务状态。`secureCredentialStore` 只接受经过校验的 `ws/wss` endpoint 和非空 token,使用 +`safeStorage` 加密写入 `AppDataRoot/credentials`;Renderer 只能写入、读取 metadata 或删除, +不能通过 IPC 读取 token。safeStorage 不可用时 fail closed,且本能力不会改变 Cloud endpoint +默认关闭的裁决。 + App Server 发起的 reverse JSON-RPC request 仍复用同一 JSONL/stdio、`app_server_drain_events` 与 `app_server_handle_json_lines` 通道。Electron 只负责从 typed connection drain notification/request,并把 Renderer 的 Response/Error 原样写回 sidecar;不得解释 server request method、生成业务 decision、持有 pending waiter 或把 request 降级成 Electron IPC 业务命令。 Desktop Host 的 sidecar 启动/恢复诊断属于宿主旁路,不是 App Server 业务 read model。`app_server_host_diagnostics` 是只读 Electron IPC 命令,由 `ElectronAppServerHost` 返回有界、脱敏的启动阶段、连接代际、sidecar 运行状态和最近失败上下文;Renderer 只能通过 `src/lib/api/desktopHostDiagnostics.ts` 做 schema 校验后把它并入现有 crash diagnostic bundle。该命令不得承载 Thread/Turn/Item、provider、工具或 session 状态,也不得替代 `app_server_handle_json_lines`。 @@ -904,12 +911,12 @@ Pending 必须在 canonical/projected/in-memory 的 Thread/session read/list、r | Crate | 准入 | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | -| `config`、`infra`、`core` | 配置、平台无关基础设施和稳定公共模型;不接受默认塞入的新 runtime 逻辑。 | -| `services`、`processor` | 有明确领域 owner 的服务与处理器;中心 facade 只做 dispatch。 | -| `knowledge`、`embedding`、`document-preview` | 独立领域能力。 | -| `gateway`、`websocket`、`server`、`server-utils` | 网络/服务边界。 | -| `providers` / `lime-providers` | `dead / deleted / forbidden-to-restore`;provider 网络、wire lowering、stream、catalog 只归 `model-provider`。 | -| `scheduler`、`automation_execution` 对应 owner | 调度与自动化领域,不承接 turn loop。 | +| `config`、`infra`、`core` | 配置、平台无关基础设施和稳定公共模型;不接受默认塞入的新 runtime 逻辑。 | +| `services`、`processor` | 有明确领域 owner 的服务与处理器;中心 facade 只做 dispatch。 | +| `knowledge`、`embedding`、`document-preview` | 独立领域能力。 | +| `gateway`、`websocket`、`server`、`server-utils` | 网络/服务边界。 | +| `providers` / `lime-providers` | `dead / deleted / forbidden-to-restore`;provider 网络、wire lowering、stream、catalog 只归 `model-provider`。 | +| `scheduler`、`automation_execution` 对应 owner | 调度与自动化领域,不承接 turn loop。 | | `cli` | Rust CLI 入口,只通过 `app-server-client` 消费 App Server 产品协议;`execpolicy check` 只读取规则文件并输出匹配结果,不承接执行或 sandbox authority。 | 新增 Rust crate 必须说明:现有 domain 为什么不适合、公开 contract 是什么、依赖方向是什么、如何避免落入 `core`/`services` 平铺层。 @@ -2382,14 +2389,28 @@ Renderer commandExec gateway -> typed App Server client -> command/exec -> App Server CommandExecServer keyed by (ConnectionId, processId) + -> App Server ExecutionProcessServer keyed by connection-scoped internal owner id -> tool-runtime LocalExecutionProcessHandle -command/exec/write|resize|terminate +command/exec/write|terminate -> same (ConnectionId, processId) + -> ExecutionProcessServer owner for stdin/terminate/timeout + -> same supervisor control handle +command/exec/resize + -> same (ConnectionId, processId) + -> same session control handle command/exec/outputDelta -> same owner connection, raw bytes as deltaBase64 ``` +公开 `processId` 仍只在 originating `ConnectionId` 内有效;为避免不同连接复用公开 ID 时污染全局执行索引, +`CommandExecServer` 将其 lowering 为 `command-exec-{connectionId}-{processId}` base internal owner id;当该 base +owner 仍被终态 snapshot/output 保留时,为下一次合法复用追加 instance UUID。该 internal id 不进入 command/exec +response 或 notification,但启动、stdout/stderr delta、终态 snapshot、超时终止、stdin 写入和断连清理均通过同一 +`ExecutionProcessServer` owner;PTY resize 继续使用同一 session control handle。这样 command/exec 与 Thread shell、 +unified exec 和 background terminal 共享 process lifecycle/status/output owner,而不复制协议层 session 映射或改变 +公开 connection-scoped 语义。 + `permissionProfile` 是唯一允许的 profile 选择入口。App Server 在 `command/exec` ingress 根据当前 YAML `default_permissions`、named profile inheritance、请求 cwd 和平台 sandbox readiness 解析 profile,再把 `sandboxPolicy` 与内部 `GrantedPermissionProfile` lowering 给 `tool-runtime`;`grantedPermissions` 不属于 @@ -2403,14 +2424,33 @@ output cap 为 1 MiB,超时退出码为 `124`;PTY resize 只允许正数尺 Desktop terminal 只消费 `src/lib/api/commandExec.ts`,通过 xterm 展示真实 outputDelta,不在 Renderer 伪造 prompt、 session reconnect、明文 stdin 或 fallback output。Electron 仅承担既有 JSONL sidecar 转发职责。 -v2 protocol/schema、CommandExecServer、connection cleanup、typed client、Renderer gateway、GUI terminal 和负向回流 -guard 为 `current`;旧 `project_shell_*`、`run_project_shell_command`、Project Shell v0 DTO/schema、旧 gateway 与 -Electron host 为 `dead / deleted / forbidden-to-restore`;`compat` 与 `deprecated` 均为空。 +v2 protocol/schema、CommandExecServer、connection cleanup、`ExecutionProcessServer` lifecycle owner、typed client、 +Renderer gateway、GUI terminal 和负向回流 guard 为 `current`;旧 `project_shell_*`、`run_project_shell_command`、 +Project Shell v0 DTO/schema、旧 gateway 与 Electron host 为 `dead / deleted / forbidden-to-restore`;`compat` 与 +`deprecated` 均为空。 -Architecture impact: major because a public JSON-RPC command family replaced the private Project Shell IPC/session owner. -Architecture map updated: sections 6.1, 33.1 and command boundary document. Responsible developer confirmation: root, -2026-08-08. Confirmation content: 已核对 Desktop/App Server/runtime owner、ConnectionId 隔离、notification 顺序、 -raw bytes lowering、删除边界和 GUI/contract 验证门禁。 +Architecture impact: major because a public JSON-RPC command family replaced the private Project Shell IPC/session owner, +and command/exec lifecycle now converges on the shared App Server process owner. Architecture map updated: sections 6.1, +33.1 and command boundary document. Responsible developer confirmation: root, 2026-09-06. Confirmation content: 已核对 +Desktop/App Server/runtime owner、公开与 internal process ID 隔离、notification 顺序、raw bytes lowering、删除边界和 +GUI/contract 验证门禁。 + +### 33.2 Unified exec command/test lifecycle + +Thread 内的 Codex `exec_command` 不再把 `Command` Item 当成只在终态读取的 batch outcome。`tool-runtime::unified_exec` +从同一个 `RuntimeLiveExecutionGateway` 读取 `ExecutionProcessSnapshot`,把 `processId`、`executionProcessStatus`、 +`stdinWritable`、bounded output counters、exit code 与 `executionSurface=unified_exec` 写入每次 canonical result; +因此短命令、yield 后仍运行的命令和 `write_stdin` 续接都保留同一 process identity。App Server +`runtime_backend::coding_events::CodingEventMirror` 消费 `Command` Item 的 `ItemStarted -> ItemUpdated -> +ItemCompleted`,统一产出 `command.started/output/exited` 与 test command 的 `test.started/completed`,Workbench/read +model 不再只依赖最后一个 ToolEnd metadata。需要 workspace sandbox backend 的命令仍由 Agent sandbox executor 承接; +unified exec 只能通过 `RuntimeLiveExecutionGateway -> ExecutionProcessServer`,不能降级裸进程或创建并行 owner。 + +这一变更仍沿用 `Product Surface -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`,不新增协议方法、 +不把 process owner 暴露为第二套 Renderer API。architecture impact: major because canonical command/test lifecycle 的 +事实源从终态 batch metadata 收敛为共享 process snapshot + Item lifecycle。Responsible developer confirmation: root, +2026-09-06. Confirmation content: 已核对 unified exec、ExecutionProcessServer、Command Item lifecycle、sandbox fail-closed +分支、read model hydrate 与现有 command/exec owner 的依赖方向。 ## 34. Exact Filesystem Owner diff --git a/internal/aiprompts/commands.md b/internal/aiprompts/commands.md index c64532ecb..5f1e6f717 100644 --- a/internal/aiprompts/commands.md +++ b/internal/aiprompts/commands.md @@ -29,17 +29,25 @@ All surfaces | 需求 | Owner | | --------------------------------------------------------------- | --------------------------------------------------- | -| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain | -| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` | -| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` | -| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host | -| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package | -| TUI composer、terminal lifecycle、terminal projection | `tui` | -| CLI 参数、非交互输出、进程退出码 | `cli` | -| 本地/未来远端 App Server 会话、请求并发和 reverse request | `app-server-client` | +| Thread / Turn / Item、read model、evidence、业务查询与写入 | App Server protocol + handler + current Rust domain | +| 模型路由、canonical content、capability、provider wire lowering | `runtime-core` / `model-provider` | +| 工具定义、审批、sandbox、dispatch、MCP | `tool-runtime` | +| 窗口、系统文件选择、通知、Dock、tray、updater、sidecar | Electron Desktop Host | +| Cloud session credential 的平台加密保存、删除与 metadata | `electron/secureCredentialStore.ts` + Desktop Host IPC | +| UI request builder、response normalization、projection | Renderer `src/lib/api/` 或 typed package | +| TUI composer、terminal lifecycle、terminal projection | `tui` | +| CLI 参数、非交互输出、进程退出码 | `cli` | +| 本地/未来远端 App Server 会话、请求并发和 reverse request | `app-server-client` | 禁止为业务调用新增第二个 Electron/CLI 后端、renderer mock fallback、临时 DevBridge 命令或 legacy wrapper。生产失败必须显式失败;mock 仅在测试夹具中显式注入。 +Cloud session credential 的 IPC 只允许 `cloud_session_credential_set`、 +`cloud_session_credential_status` 和 `cloud_session_credential_delete`。写入由 +Electron `safeStorage` 加密后落在统一 `AppDataRoot/credentials`,状态接口只返回 +`exists`、`tenantId`、endpoint 和更新时间,不提供 token 读取 IPC。token 不得进入 URL、 +query、Debug 或日志;safeStorage 不可用时必须 fail closed。该 owner 只是 Cloud +transport foundation,不会启用默认远端 App Server,也不替代 App Server JSON-RPC。 + ## TUI/CLI 与 Cloud transport 边界 `app-server-client` 是所有非 Renderer Rust surface 的会话 owner。它负责 JSON-RPC request id、并发 pending request、notification、reverse server request、断线与关闭;`tui` 和 `cli` 只能消费其 typed facade。当前 production transport 支持本地 stdio 与受控 authenticated WebSocket foundation。transport trait 是两种实现共用的依赖倒置边界;远端 token 仅允许 `wss://` 或 loopback `ws://`,remote 默认固定 `app-server` 身份和 `appserver.v0` 协议版本,生产 Cloud endpoint 仍需在租户、重连和审计策略明确后单独启用,不增加假 endpoint 或 fallback。 @@ -182,11 +190,14 @@ root 并重建受管目录,不删除 Thread/Turn/Item、event log、projection Codex exact 独立命令执行与 Desktop 交互终端只允许走 connection-scoped App Server JSON-RPC: -`src/lib/api/commandExec.ts -> typed App Server client -> command/exec -> App Server CommandExecServer -> tool-runtime local process supervisor` +`src/lib/api/commandExec.ts -> typed App Server client -> command/exec -> App Server CommandExecServer -> ExecutionProcessServer -> tool-runtime local process supervisor` 一次性命令通过 `command/exec` 返回 `exitCode/stdout/stderr`;流式命令通过 `command/exec/outputDelta` 投影 raw bytes 的 `deltaBase64`,并由同一连接内的 `processId` 过滤。交互终端的 -输入、PTY 尺寸和终止分别使用 `command/exec/write`、`command/exec/resize`、`command/exec/terminate`。 +输入、PTY 尺寸和终止分别使用 `command/exec/write`、`command/exec/resize`、`command/exec/terminate`;stdin 写入、超时终止、 +断连清理和 lifecycle/status/output owner 统一复用内部 `ExecutionProcessServer`。公开 `processId` 仍按 +`ConnectionId` 隔离,内部 owner ID 以 `command-exec-{connectionId}-{processId}` 为 base;终态 owner 仍保留时,后续 +复用追加 instance UUID,不进入 command/exec wire。 `outputBytesCap`、`timeoutMs` 保持 omitted/null/value 语义;stdin close 后的非空写入、非 TTY resize、零值尺寸、 未知 process id 和同一连接重复 active id 均 fail closed。断连、response 发送失败或 notification writer 失败都清理该 连接拥有的进程。Electron 只转发 App Server JSONL,不持有第二套终端会话、轮询 drain 或 renderer mock fallback。 diff --git a/internal/exec-plans/README.md b/internal/exec-plans/README.md index 00147744d..270404f03 100644 --- a/internal/exec-plans/README.md +++ b/internal/exec-plans/README.md @@ -28,12 +28,14 @@ - 路线图主线:`internal/roadmap/` - Runtime 当前对齐入口:`internal/aiprompts/architecture.md` 与 `internal/exec-plans/codex-alignment-v1-coordination-plan.md` - Refactor v2 全项目 Gate A/B 验收计划:`internal/exec-plans/project-gate-a-b-acceptance-plan.md` -- 当前发布执行计划:`internal/exec-plans/release-v1.141.0-plan.md` +- 当前发布执行计划:`internal/exec-plans/release-v1.142.0-plan.md` - 已结束版本的发布过程不保留在 active tree;结果以 Git tag、GitHub Release、Release Notes 与 Git history 为准。 - Codex App GUI 对齐执行计划:`internal/exec-plans/codex-app-gui-alignment-plan.md` - Codex Desktop 跨平台底层对比与对齐计划:`internal/exec-plans/codex-desktop-platform-parity-plan.md` - Codex Desktop 选择性 Goose 参考执行计划:`internal/exec-plans/codex-desktop-selective-goose-reference-plan.md` - Desktop + CLI/TUI 多 Surface 执行计划:`internal/exec-plans/tui-cli-surfaces-plan.md` +- TUI/CLI 继续同步 Codex 执行计划:`internal/exec-plans/tui-cli-codex-sync-next-plan.md` +- Codex/Lime TUI/CLI 全量差异报告:`internal/exec-plans/codex-lime-tui-cli-difference-report.md` - Codex TUI snapshot 逐项分类账本:`internal/exec-plans/tui-codex-snapshot-inventory.json` - Codex `fs/changed` Renderer 消费链对齐计划:`internal/exec-plans/fs-changed-codex-alignment-plan.md` - Codex CLI Rust 测试逐项分类账本:`internal/exec-plans/cli-codex-test-inventory.json` diff --git a/internal/exec-plans/codex-alignment-v1-coordination-plan.md b/internal/exec-plans/codex-alignment-v1-coordination-plan.md index db2625a9e..0c5e9a863 100644 --- a/internal/exec-plans/codex-alignment-v1-coordination-plan.md +++ b/internal/exec-plans/codex-alignment-v1-coordination-plan.md @@ -5323,3 +5323,81 @@ projection 收敛;冷启动 orphan 与被新 active Turn 取代的旧 Turn 仍 本轮还修复了受控 OpenAI-compatible fixture 的生命周期竞态:`server.keepAliveTimeout=1_000` 会在 Go 题工具间隔中截断已 `response-finish` 但客户端尚未消费的 SSE 复用 socket,使 turn 永久保持 `inProgress`。fixture 现在将 idle 连接回收统一留给显式 `close()`,不强杀 active response;定向 fixture/DeepSWE 回归 `46/46`,最终 Go 与五题复跑均通过。该修复属于 `current / controlled-fixture-lifecycle`;旧同进程页面 reload 证据属于 `dead / historical-only`,无 `compat / deprecated` 新增。 验证:单题 Go Gate B、完整五题 controlled smoke、desktop aggregate、DeepSWE Desktop contract/provider fixture 定向回归均通过;本轮未安装依赖、未调用 live provider、未运行 Docker/Podman/nerdctl/Colima/Pier verifier,因此 DeepSWE score、Pier artifacts、`DesktopCodingPass` 和 DSW-11 仍保持 blocked/incomplete。 + +### 2026-09-06 P2-A bounded process output 单一 owner 收口 + +状态:`completed / current`。 + +主目标与写集:本切片直接关闭 coding 路线图 P2-A,将 sandbox/process backend 在进入 +RuntimeCore 前的输出语义收敛到 `tool-runtime::execution_process`。窄写集仅包含 +`lime-rs/crates/tool-runtime/src/execution_process{.rs,/**}`、`tool-runtime/src/unified_exec.rs`、 +`lime-rs/crates/app-server/src/execution_process{.rs,/tests.rs}`、四份 `internal/roadmap/coding/**` 路线图与本记录; +工作树其他 Electron、TUI、App Server runtime、client package 和前端热区不在本切片写集。 + +完成结果: + +- 新增 `BoundedProcessOutput`唯一 retained-output owner,默认上限 1 MiB,稳定保留 head 与最新 tail, + 精确统计 `omitted_bytes`,并在 snapshot 中插入 `... N bytes omitted ...`。 +- 本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远程 Environment 共享同一输出边界; + 远程旧 128 KiB tail-only 截断已删除。 +- reader -> process handle 改为 64-slot bounded broadcast;慢消费者可跳过中间 delta,但权威 terminal + snapshot 仍保留有界 head/tail,无消费者时大输出也不会堵塞子进程。 +- `ProcessOutputFramers` 对 stdout/stderr/combined 独立保留最多 3 个不完整字节,只在完整 + UTF-8 scalar 边界发 lifecycle delta;正常 EOF、PTY/Windows terminal 与普通 pipe 100 ms grace + 超时中止 reader 后均 flush 未完整后缀,raw transcript、replay 与 omission 统计不重复计数。 +- unified exec observation 改用同一有界缓冲;App Server replay 改为逐进程 queue, + `drain_output` 必须明确 `process_id`,不再用全局 FIFO 让一个进程驱逐另一进程输出。 + +验证结果:`npm run test:rust:unit -- -p tool-runtime unified_exec` 11/11 通过; +`npm run test:rust:unit -- -p app-server execution_process` 20/20 通过;修正远程 chunk sequence +告警后 `execution_process_server_uses_environment_process_transport` 1/1 通过且零告警。 +`npm run test:rust:unit -- -p tool-runtime execution_process` 28/28 通过,包含普通 pipe grace-timeout +UTF-8 flush 回归。Windows 当前 SHA 的 restricted-token 大输出真机 +evidence 未运行,历史 schema v3 7/7 不提升为当前 SHA 平台证据。 +跨 crate `npm run test:rust:related -- ...execution_process...` 完成编译并运行 7 个反向依赖的 +library tests;主体通过,但被工作树既有并行热区 `runtime_backend/coding_events` 中的 +`canceled_command_item_projects_canceled_test_result` 与 +`canonical_command_completion_emits_redacted_terminal_interaction_before_raw_item` 两项断言阻塞(`1776/1780`); +两项 external backend JSONL timeout 独立复跑各为 `1/1` 通过,判定为并发运行抖动。本切片不越界修改该并行写集。 +`npm run test:contracts`全绿:1034 协议类型零漂移、App Server client 299 checks,命令、harness、modality、scripts、Electron release、CLI 与 docs boundary 均通过。 +`npm run smoke:agent-runtime-current-fixture` 全绿:真实 Electron/preload/IPC/App Server/runtime/read model/GUI 聚合通过,`liveProviderUsed=false`。 +`npm run governance:legacy-report`全绿:2063 文件扫描、零引用候选、零分类漂移、零边界违规;`rustfmt --check` 与 `git diff --check` 通过。 + +分类:`BoundedProcessOutput`、`ProcessOutputFramers`、各 backend 统一捕获、unified exec 有界 observation +与 App Server per-process replay 为 `current`;128 KiB 默认、手写 tail-only capture、全局跨进程 FIFO、 +unbounded output channel 与逐 chunk lossy UTF-8 delta 为 `dead / deleted / forbidden-to-restore`; +无 `compat / deprecated`。 + +架构影响:非重大。本切片没有改变 crate/package、App Server JSON-RPC 协议、领域 owner 或依赖方向, +只在既有 `tool-runtime -> App Server -> RuntimeCore` 主链内收敛输出边界,因此不更新架构图。 +本切片完成度 `100%`;P2-A/P2-B/P2-C current owner 已闭环,coding 整体工程闭环估算 `99.4%`, +下一刀直接进入当前 tree 的 Pier/DeepSWE verifier 与正式 distribution evidence。 + +### 2026-09-06 Pier verifier 环境注入与真实重跑 + +状态:`blocked / verifier-environment`。 + +为恢复已有候选 run +`.lime/benchmark/v2/runs/20260827T060242Z-oxvg-structural-selector-preservation` 的真实 Pier +判分,`runPierVerifier()` 新增隔离 Docker CLI 配置和当前 Docker context endpoint 注入:优先使用 +`LIME_DEEPSWE_DOCKER_CONFIG`,否则使用继承配置或仓库隔离配置;当 `DOCKER_HOST` 未显式提供时, +从 `docker context show/inspect` 解析 endpoint 并传给 Pier 子进程。适配器测试新增 context endpoint +回归,定向测试从 `25/25` 提升为 `26/26`。 + +真实重跑记录:首次重跑已越过旧的 Compose plugin 不可见问题和 Docker daemon preflight,Pier +创建了 `verify-20260827T060242Z-oxvg-structural-selector-preservation-retry-1`,但在拉取 +`public.ecr.aws/d3j8x8q7/swe-bench-202605:kh749pxv8w35vksyfhe7p8tg8x82p0vg-v1.1` 时失败: +Colima guest 的 dockerd 使用不可用的 `[::1]:53` DNS,未继承 guest `/etc/environment` 中的 +`192.168.5.2:7890` HTTP(S) 代理。当前 run 没有生成 `reward.json`、`ctrf.json` 或有效的 +`test-stdout.txt`,不得提升为 verifier pass、`reward=1`、`DesktopCodingPass` 或 DSW-11 完成。 + +验证:`npx vitest run scripts/harness/deepswe-adapter.test.mjs` 26/26;Prettier、 +`npm run test:contracts`、`npm run governance:legacy-report` 和 `git diff --check` 全绿。 +Colima 已启动且 `docker compose version` 为 `v5.1.4`;宿主机直接访问 `public.ecr.aws` 成功, +但 Docker daemon 镜像拉取仍因 DNS/代理配置失败。当前唯一 blocker 是为本地 Colima Docker daemon +注入代理或可用 DNS 并重启 daemon;该系统配置变更需显式确认后执行。 + +分类:Docker context/Compose plugin 注入、adapter 回归与候选 run 证据为 `current`;缺失 endpoint +导致的 `/var/run/docker.sock` 回退已修复;本地 daemon 未配置代理导致的 verifier 失败为 +`blocked`,无 `compat / deprecated`。下一刀在确认系统配置变更后重跑同一候选 run,不重新执行 +live provider。 diff --git a/internal/exec-plans/codex-desktop-complete-alignment-plan.md b/internal/exec-plans/codex-desktop-complete-alignment-plan.md index 2c62322c6..0686bc894 100644 --- a/internal/exec-plans/codex-desktop-complete-alignment-plan.md +++ b/internal/exec-plans/codex-desktop-complete-alignment-plan.md @@ -192,6 +192,7 @@ Renderer typed gateway 当前子进度: - [x] C-1 已确认 `command/exec` 与 standalone `process/*` 归 connection-scoped App Server + `tool-runtime::execution_process` current owner;实时 raw-byte delta、stdin/PTY、interrupt、terminate、timeout、bounded output、跨连接隔离、响应前 notification barrier 和断连清理均已有 owner 与回归覆盖,不新增第二套 process supervisor。 +- [x] C-1 unified exec lifecycle slice(2026-09-06):`tool-runtime::unified_exec` 将同一 `ExecutionProcessSnapshot` 的 process/status/stdin/bounded-output facts 写入 canonical result;App Server `CodingEventMirror` 消费 `Command` Item 的 `ItemStarted -> ItemUpdated -> ItemCompleted`,投影统一的 `command.started/output/exited` 与 `test.started/completed`,并复用现有 `ExecutionProcessServer`,不新增 JSON-RPC 或并行 supervisor。统一 exec 与 sandbox-aware `RuntimeLiveExecutionGateway` 的 owner 方向已对齐;定向 Cargo 测试因本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 未执行成功。 - [x] C-2 已接入 session-scoped shell approval cache:key 绑定 command、cwd、requested sandbox policy 和 requested permissions;仅 `exec_command` 支持 `allow_for_session`,browser/guardian 不复用 shell cache;provider session close 清理内存 cache,且 cache 命中使用 `RuntimeToolApprovalSource::Reused` 跳过重复用户审批。 - [x] C-2 sandbox-denied retry 已由统一 orchestrator 承接:按 approval policy 判断是否允许 danger-full-access 重试;普通 user approval 复用于 sandbox retry,strict guardian 重新审核,拒绝和 cancel fail closed;新增 Cached 零审批、key 隔离、shell/browser contract 回归测试。 - [x] C-3 Windows 安全证据入口已落到 current `tool-runtime::execution_process` owner:新增 `windows_restricted_execution` integration test,并接入 `.github/workflows/quality.yml` 的 Windows runner;测试矩阵覆盖 workspace write、`.git/.codex/.agents` 与外部路径拒绝、allowlisted stdin handle、bounded large output、Job Object descendant cleanup/terminate。 diff --git a/internal/exec-plans/codex-lime-tui-cli-difference-report.md b/internal/exec-plans/codex-lime-tui-cli-difference-report.md new file mode 100644 index 000000000..d384e6c15 --- /dev/null +++ b/internal/exec-plans/codex-lime-tui-cli-difference-report.md @@ -0,0 +1,366 @@ +# Codex 与 Lime TUI/CLI 全量差异报告 + +基线:Codex checkout `/Users/coso/Documents/dev/rust/codex`,Rust commit +`cac96cd7b1756ab42e8925d938817a2ac10ebb6e`。本报告只记录对照结果,不把 Codex +私有产品能力误判成 Lime current 需求。逐项路径、符号、哈希、测试名和分类以以下 +JSON 为最终事实源: + +- [TUI 目录/符号账本](./tui-structure-inventory.json) +- [TUI 802 个 snapshot 账本](./tui-codex-snapshot-inventory.json) +- [CLI 目录/符号账本](./cli-structure-inventory.json) +- [CLI 433 个测试账本](./cli-codex-test-inventory.json) + +## 1. 总量差异 + +| 维度 | Codex | Lime | 差异 | 结论 | +| --- | ---: | ---: | ---: | --- | +| TUI Rust 源文件 | 579 | 91 | 缺 506,Lime 独有 18 | 目录体系未同构 | +| TUI Rust 类型/函数符号 | 12,000 | 1,491 | 缺 9,878,Lime 独有 791 | 大量行为仍聚合在 Lime owner | +| TUI snapshot | 802 | 0 | 802 未复制为 insta 文件 | 账本已分类,未机械迁移 | +| TUI 测试标记 | 4,145 | 372 | 缺 3,773(以扫描口径) | Cargo 实际 TUI 测试 390/390 | +| CLI Rust 源文件 | 82 | 11 | 缺 71 | 产品专属文件被排除,current 仍较薄 | +| execpolicy 源文件 | 17 | 17 | 0 | 目录同构 | +| CLI npm 文件 | 7 | 7 | 3 个文件名不同 | launcher 已改为 Lime 命名 | +| Codex CLI 测试 | 433 | - | 50 covered,88 partial,64 deferred,231 excluded | `missing=0` 不等于行为全覆盖 | + +## 2. TUI 目录与文件差异 + +### 2.1 Codex 缺失于 Lime 的文件族 + +`filesMissingInLime=506`,完整路径清单在 TUI 结构账本。按一级 owner 计数如下: + +| owner | 缺失数 | 主要差异 | +| --- | ---: | --- | +| 根级文件 | 112 | startup、config、terminal probe、history、streaming 等大量 owner | +| `app/` | 69 | history UI/pagination、startup、resize/reflow、safety buffering、thread state/routing、完整 app tests | +| `bottom_pane/` | 75 | footer、popup、selection、mentions、skills、hooks、textarea/vim、unified exec | +| `chatwidget/` | 110 | Codex 主交互状态机、输入流、权限、工具生命周期、replay、streaming、settings | +| `history_cell/` | 20 | approval、exec、MCP、patch、plan、request_user_input、session 等历史项渲染 | +| `render/` | 7 | renderable、line utils、streaming highlight | +| `streaming/` | 10 | chunking、fence、controller、commit tick、table holdback、render | +| `status/` | 10 | account/card/format/rate limits/remote/thread usage | +| `tui/` | 12 | history tail、input boundary、job control、keyboard modes、scrollback、Windows console | +| 其它目录 | 81 | `onboarding`、`pets`、`terminal_probe`、`ide_context`、`notifications`、`keymap_setup`、`public_widgets` 等 | + +重点缺失的 Codex 同名顶层 owner: + +`chatwidget.rs`、`history_cell/`、`render/`、`streaming/`、`terminal_probe.rs`、 +`cwd_prompt.rs`、`session_start.rs`、`session_resume.rs`、`app_command.rs`、 +`app_event.rs`、`app_event_sender.rs`、`app_server_connection.rs`、 +`app_server_approval_conversions.rs`、`backend_banners.rs`、`dynamic_tools.rs`、 +`dynamic_tools_mcp.rs`、`file_search.rs`、`hooks_rpc.rs`、`ide_context.rs`、 +`terminal_title.rs`、`tooltips.rs`、`ui_consts.rs`、`workspace_command.rs`。 + +### 2.2 Lime 独有文件 + +这些文件在 Codex 无同名 owner,属于结构差异或待收敛 owner: + +`bottom_pane/render.rs`、`command_popup.rs`、`entry.rs`、`highlight.rs`、`locale.rs`、 +`markdown/local_links.rs`、`markdown/table.rs`、`model_picker.rs`、 +`pending_input_preview.rs`、`projection.rs`、`projection_tests.rs`、`reconnect.rs`、 +`runtime.rs`、`runtime_pty_tests.rs`、`settings.rs`、`status_indicator.rs`、 +`view.rs`、`viewport.rs`。 + +其中 `projection.rs`、`runtime.rs`、`view.rs` 是 Lime 聚合 owner;`reconnect.rs` 是 +顶层委托壳,真实 current owner 已在 `app/reconnect.rs`,且当前未发现消费者; +`viewport.rs` 已有 Codex-shaped state/test,但尚未完全接入 runtime resize/reflow 主路径。 + +### 2.3 Codex 集成测试目录缺失 + +Lime 没有 `crates/tui/tests/` 集成测试目录。Codex 的以下 11 个测试文件没有同构副本: + +`tests/all.rs`、`tests/test_backend.rs`、`tests/manager_dependency_regression.rs`、 +`tests/suite/focus_palette.rs`、`tests/suite/reconnect.rs`、 +`tests/suite/resize_reflow.rs`、`tests/suite/status_indicator.rs`、 +`tests/suite/vt100_history.rs`、`tests/suite/vt100_live_commit.rs`、 +`tests/suite/mod.rs`、`tests/fixtures/oss-story.jsonl`。 + +因此当前差异是:Lime 主要依赖 inline unit tests、`projection_tests.rs`、 +`runtime_pty_tests.rs` 和 Gate B;Codex 还有真实 `vt100` history/live-commit、focus、 +resize/reflow、reconnect 集成套件和独立测试 backend/support。 + +## 3. TUI 符号差异 + +结构扫描结果为 Codex `12,000` 个符号、Lime `1,491` 个符号: + +- `symbolNamesMissingInLime=9,878`:完整名称、路径和类型在 + `tui-structure-inventory.json.comparisons.symbolNamesMissingInLime`。 +- `symbolNamesOnlyInLime=791`:完整名称、路径和类型在 + `tui-structure-inventory.json.comparisons.symbolNamesOnlyInLime`。 +- 缺失符号主要集中于 `ChatWidget`/输入流、`HistoryCell`、`Renderable`、 + streaming controller、permission/approval、MCP、startup/history、terminal probe、 + account/status 和 Codex 私有产品面。 +- Lime 独有符号主要来自 `Projection`、`Runtime`、`View`、`ModelCatalog`、 + `CollaborationModes`、resume/archive 聚合 owner,以及当前 App Server canonical + projection 的类型。 + +不能把符号同名率当作语义覆盖率:Codex 私有 runtime 状态必须通过 App Server JSON-RPC +canonical contract 重建,不能直接把私有类型搬进 Lime。 + +## 4. TUI snapshot 差异 + +Codex snapshot 基线为 802 个,Lime 当前 snapshot 文件为 0;账本已逐项保存路径和 SHA-256。 + +| Codex 模块 | snapshot 数 | 分类 | +| --- | ---: | --- | +| `app` | 56 | contract/dead | +| `bottom_pane` | 244 | merge/dead | +| `chatwidget` | 278 | merge/dead | +| `history_cell` | 56 | merge/dead | +| `status` | 23 | merge/dead | +| `resume_picker` | 18 | contract | +| `markdown_render` | 20 | direct | +| `diff_render` | 23 | direct | +| `pager_overlay` | 9 | merge | +| `status_indicator_widget` | 8 | merge | +| `cwd_prompt` | 5 | contract | +| `onboarding` | 4 | dead | +| `model_migration` | 4 | dead | +| `inline_visualization` | 4 | defer | +| `custom_terminal` | 3 | defer | +| `debug_config` | 3 | dead | +| 其它模块 | 44 | 按账本逐项分类 | + +分类总计:`direct=48`、`merge=579`、`contract=80`、`defer=25`、`dead=70`。 + +- `direct`:`diff_render`、`markdown_render`、`insert_history`、`render`、 + `terminal_hyperlinks`、`terminal_palette`、`table_detect`、`wrapping` 等纯终端算法。 +- `merge`:与 Lime 交互相关、但必须合入 `app/composer/entry/view/picker` current owner。 +- `contract`:跨 App Server、Thread/Turn/Item、历史恢复、multi-agent 或持久化状态。 +- `defer`:`custom_terminal`、`git_action_directives`、`inline_visualization`、 + `keymap_setup`、`startup_hooks_review`。 +- `dead`:Codex account、onboarding、migration、update、marketplace、Luna reserve、 + ChatGPT plan/account、产品专属 debug/config 等。 + +## 5. TUI 能力差异裁决 + +### 已对齐或已有 current owner + +App Server 事件/请求分离、ThreadEventStore、replay filter、agent picker、resume/archive、 +queue edit、approval、request_user_input、interrupt、external editor、model catalog、 +多 agent overview、terminal restore、clipboard、markdown/diff/hyperlink/wrapping、 +status indicator、真实 PTY Gate B 均已有 current owner 和回归测试。 + +### 部分对齐 + +1. Codex `chatwidget` 的完整状态机被 Lime `app` + `bottom_pane/chat_composer` + `projection` + 分散承接,目录和类型没有一一对应。 +2. Codex `history_cell` 的历史项渲染被 Lime `entry.rs`/`thread_transcript.rs` 承接, + 但 approval、MCP、patch、plan、hook、exec 的完整 cell owner 和 snapshot 套件未同构。 +3. Codex `render`/`streaming` 被 Lime `view.rs`、`entry.rs`、`markdown.rs`、`highlight.rs` + 分散承接,没有独立 `render` 和 `streaming` owner。 +4. Codex `status`、`backend_banners`、`rate_limit_refresh`、`safety_buffering`、 + `misalignment_policy`、`history_ui`、`working_directory`、`session_start/resume` 仍有 + 行为或测试缺口。 +5. `viewport.rs` 已建立,但 resize/reflow 主路径还没有完全接入。 +6. `reconnect.rs` 顶层壳仍在,应该在确认无引用后删除并保留负向守卫。 + +### 明确暂缓 + +`custom_terminal`、`git_action_directives`、`inline_visualization`、`keymap_setup`、 +`startup_hooks_review`;另外包括 Codex 完整 vt100 snapshot suite、Windows terminal replay、 +以及非当前 Desktop parity 优先级的产品能力。 + +### 明确禁止复制 + +Codex account/onboarding/update/marketplace/pets/theme、Luna reserve、ChatGPT 专属状态、 +Codex daemon/state DB、Codex rollout/history DB、Codex misalignment 专属协议枚举、 +第二套 runtime、`TerminalGuard`、`TuiTerminal`、旧 `lime-cli`、`terminal-ui`、旧 +bounded-EOF restart 和旧 crossterm registry 输入路径,均为 `dead/deleted/forbidden-to-restore`。 + +## 6. TUI Cargo/依赖差异 + +Codex TUI 是独立产品 crate: + +- package `codex-tui`,`autobins=false`。 +- binary `codex-tui`,另有 `md-events` binary。 +- lib name `codex_tui`。 +- Codex 私有依赖覆盖 config/login/rollout/state/model-provider/sandboxing/plugin/ + connectors/history/feedback/file-search/cloud-config/otel/terminal-detection 等 owner。 +- 测试依赖包括 `insta`、`wiremock`、`serial_test`、`assert_matches`、`vt100`、Codex test support。 + +Lime TUI 当前为: + +- package `tui`,只有 lib `tui`,没有 `tui` binary 和 `md-events`。 +- 依赖收敛到 `app-server-client`、`app-server-protocol`、`agent-protocol`、 + `tool/runtime` 现有 workspace owner;不复制 Codex 私有 crate。 +- 已对齐 crossterm fork 和 feature: + `https://github.com/openai-oss-forks/crossterm@45fecb9508105988f42fe6ff0441783ed3717f92`, + `bracketed-paste` + `event-stream`。 +- 仍缺 Codex 的独立 binary、`insta` snapshot 测试基础设施、完整 test backend/support。 + +## 7. CLI Rust 目录与符号差异 + +### 7.1 文件 + +Codex `codex-rs/cli` 为 82 个文件,Lime `lime-rs/crates/cli` 为 11 个文件。 +Lime current 文件为: + +`debug_sandbox.rs`、`exit_status.rs`、`lib.rs`、`main.rs`、`mcp_cmd.rs`、`plugin_cmd.rs`、 +`queue_cmd.rs`、`sandbox_setup.rs`、`wsl_paths.rs`、`Cargo.toml`、`tests/execpolicy.rs`。 + +Codex 缺失文件完整路径在 `cli-structure-inventory.json.comparisons.rustFilesMissingInLime`, +其中包括: + +- 产品/诊断:`src/doctor/**`、`src/login.rs`、`src/marketplace_cmd.rs`、`src/app_cmd.rs`、 + `src/bin/logs_client.rs`、`src/state_db_recovery.rs`、`src/migrate_rollouts.rs`。 +- Cloud/远程:`src/cloud_config.rs`、`src/remote_control_cmd.rs`、 + `src/exec_server_telemetry*.rs`、`src/debug_sandbox/cloud_config*.rs`。 +- 平台私有:`src/desktop_app/**`、`src/debug_sandbox/pid_tracker.rs`、 + `src/debug_sandbox/seatbelt.rs`。 +- 测试:`tests/app_server.rs`、`tests/cloud_*.rs`、`tests/debug_*.rs`、`tests/features.rs`、 + `tests/mcp_*.rs`、`tests/plugin_cli.rs`、`tests/queue.rs`、`tests/sandbox_*.rs`、 + `tests/marketplace_*.rs`、`tests/login.rs`、`tests/update.rs` 等。 + +### 7.2 符号 + +- CLI Rust 缺失 Codex 符号 `774` 个,Lime 独有 `65` 个;完整路径/名称在结构账本。 +- `execpolicy` 为 17/17 文件、95/95 符号,同构完成。 +- Lime current 命名已按 Codex 形状收敛:`MultitoolCli`、`Subcommand`、`CompletionCommand`、 + `TuiCli`、`ExecCli`、`ResumeCommand`、`McpCli`、`PluginCli`、`FeaturesCli`、 + `QueueCommand`、`DebugCommand`、`SandboxStateArgs`、`SandboxSetupCommand`、 + `handle_exit_status` 等。 +- 当前仍有 Lime 独有命名/产品扩展:`SkillsCli`、`ThreadCommand`、`ConnectionArgs`、 + `QueueSubcommand`、`FeatureListArgs`、`PluginIdArgs` 等,需要继续确认是否应成为 + current contract 或删除。 + +## 8. CLI 测试差异 + +Codex CLI 账本共 433 个测试,来自 53 个源文件。当前分类: + +| 分类/状态 | 数量 | 含义 | +| --- | ---: | --- | +| `direct / covered` | 9 | 纯 parser、WSL、execpolicy 等直接覆盖 | +| `contract / covered` | 41 | 通过 App Server JSON-RPC/current owner 覆盖 | +| `contract / partial` | 88 | 有 current owner,但行为或 Gate B 未完全同构 | +| `cloud-deferred / deferred` | 64 | authenticated remote transport 基础完成前暂缓 | +| `product-specific / excluded` | 231 | Codex account/doctor/marketplace/updater/desktop/state 私有能力 | +| `missing / pending` | 0 | 每个测试都已明确绑定到分类;不代表行为全部实现 | + +### 已覆盖 current CLI 行为 + +parser command tree、sandbox parser、sandbox command lowering、sandbox setup parser、 +execpolicy check、debug models/clear memories、features list/enable/disable、基本 plugin +add/list/read/search/enable/disable/remove、MCP list/add/get/remove/start/stop、queue add/list、 +thread archive/delete/unarchive/fork/resume、非交互 exec、TUI/exec 入口、WSL path 算法和 +退出码处理。 + +### 88 个 partial 的能力族 + +1. permission options:Codex `approve-for-me`、`not-so-yolo` 别名和 root/exec/resume 合并 + 优先级未逐字复制;Lime 以 `permissionProfile/list` + `turn/start` 为 current owner。 +2. plugin control plane:基本 CRUD 已覆盖,marketplace cache、远程 catalog refresh、 + 未配置 repo-local marketplace 等 Codex 边界未同构。 +3. MCP mutation:OAuth registration/logout 尚无 `mcpServer/oauth/*` App Server contract, + 当前必须 fail closed,禁止 CLI 直接读 credential store。 +4. queue:本地 queue 行为已覆盖,远程 queue 证据随 Cloud deferred。 +5. app-server entrypoint:CLI 转发参数已保留,transport/daemon/proxy/signed/capability + 语义仍由 sibling App Server owner 承担。 +6. thread lifecycle:canonical resume/archive/delete/unarchive/fork 已接入,但 Codex 私有 + daemon/state/rollout storage 不复制。 +7. sandbox:sandbox-state replay、managed network、named profile 全矩阵仍不完整。 + +### 64 个 Cloud deferred 能力族 + +remote-control、exec-server、remote API-key auth、Cloud managed permission profile、 +Cloud MCP config/OAuth、remote plugin catalog、remote queue、remote working directory、 +parent lifetime/telemetry、远程 sandbox 和所有 authenticated transport 相关测试。 +完整测试名在 `cli-codex-test-inventory.json` 中 `status=deferred` 的条目。 + +### 231 个 product-specific 排除能力族 + +Codex `doctor` 全部诊断面、account/login、marketplace add/remove/upgrade、updater/update、 +desktop launcher、macOS PID tracker/seatbelt denial internals、state DB recovery、rollout +migration、logs client、Codex 内部 support client 和已退役 command alias。完整测试名在账本中 +`status=excluded` 的条目;这些不能恢复成 Lime compat/current。 + +## 9. npm/launcher 差异 + +### 文件 + +Codex `codex-cli`:`.gitignore`、`bin/codex.js`、`package.json`、`scripts/README.md`、 +`scripts/build_npm_package.py`、`scripts/init_firewall.sh`、`scripts/run_in_container.sh`。 + +Lime `packages/cli`:`.gitignore`、`README.md`、`bin/lime.js`、`package.json`、 +`scripts/README.md`、`scripts/build_npm_package.py`、`tests/npm-package.test.mjs`。 + +Lime 缺少 `init_firewall.sh`、`run_in_container.sh`;两者属于 Cloud/容器专属,不应伪造 +成生产入口。Lime 多了 README 和 npm package test。 + +### manifest + +| 字段 | Codex | Lime | 状态 | +| --- | --- | --- | --- | +| package | `@openai/codex` | `@limecloud/lime` | 品牌差异,按产品要求为 Lime | +| bin | `codex` | `lime` | 已收敛到 Lime | +| version | `0.0.0-dev` | `1.141.0` | 发布策略差异 | +| Node | `>=16` | `>=18` | 运行时基线差异 | +| pnpm | `10.34.5` | `9.15.9` | workspace 基线差异 | +| license | Apache-2.0 | MIT | 产品法律文本差异 | +| scripts | 无 | `build:npm`、`test` | Lime 增加构建/测试入口 | + +### 平台包 + +Codex 支持 `x86_64-unknown-linux-musl`、`aarch64-unknown-linux-musl`、 +`x86_64-apple-darwin`、`aarch64-apple-darwin`、`x86_64-pc-windows-msvc`、 +`aarch64-pc-windows-msvc`,并在 Linux 分支同时覆盖 Android。 + +Lime 当前只有 `x86_64-unknown-linux-gnu`、`x86_64-apple-darwin`、 +`aarch64-apple-darwin`、`x86_64-pc-windows-msvc`;缺 Linux ARM64、Linux musl、Windows ARM64、 +Android/Linux musl 分支。 + +### launcher 命名和实现 + +Lime launcher 源码仍残留 Codex-shaped 变量/函数名:`codexPackageRoot`、 +`findCodexExecutable`、`isPnpmOwnedCodexInstall`;这是当前未完成的命名差异,不能把 +Codex 名字留在 Lime current owner 中。应改为 Lime 语义(例如 package root 和 +`findLimeExecutable`),并同步 npm 测试和结构账本。Lime 额外设置 +`DYLD_LIBRARY_PATH`/`LD_LIBRARY_PATH` 以加载 bundled native libraries,这是运行时适配, +不是 Codex 同构差异。 + +## 10. 架构与 Cloud 预留 + +唯一业务主链必须保持: + +`Desktop Host 或 CLI/TUI Host -> App Server JSON-RPC -> RuntimeCore -> Thread/Turn/Item projection`。 + +Cloud 只能作为 `app-server-client` 的 authenticated transport,继续消费同一 protocol、 +runtime 和 canonical read model;不能复制 runtime、工具 registry、状态机或持久化。 + +因此下列“差异”是架构约束,不是待机械复制项:Codex 私有 crate 依赖、Codex daemon/state DB、 +Codex rollout/history DB、hosted `--code-mode-host` URL、bubblewrap network proxy、 +account/onboarding/update/marketplace/doctor/pets/theme,以及 Codex 产品内部 telemetry。 + +## 11. 运行证据差异 + +已验证: + +- `cargo test --locked -p tui -p cli`:TUI 390/390,CLI 50/50。 +- `cargo clippy --locked -p tui --no-deps -- -D warnings`、`cargo check --locked -p tui`。 +- `cargo metadata --locked`、`cargo fmt --package tui -- --check`、`git diff --check`。 +- TUI/CLI inventory Vitest、`npm run test:contracts`、`npm run governance:legacy-report`。 +- TUI Gate B 七场景通过;`complete` 场景连续 5 次通过,包含 queue edit、agents overview、 + external editor、approval/request_user_input、interrupt、terminal restored。 + +仍缺: + +- Codex `insta` snapshot 全量迁移或等价 contract snapshot。 +- Codex `tests/suite` 的 focus/reconnect/resize/vt100 集成套件。 +- Windows 原生 TUI/CLI/packaged 运行证据和完整 terminal probe replay。 +- npm Linux musl/ARM64、Windows ARM64 平台包 Gate B。 +- MCP OAuth App Server contract、Cloud authenticated transport 真实 Gate B。 +- runtime resize/reflow 对 `viewport.rs` 的完整接线。 + +## 12. 最终结论与优先级 + +当前状态不是“Codex TUI/CLI 已完全复制”,而是“Codex-shaped Lime current host 已可运行, +且所有已知差异已进入账本”。最重要的剩余差异按优先级为: + +1. 补齐 Lime `chatwidget/history_cell/render/streaming` 的 current owner 拆分和测试基础设施, + 同时把可迁移的 direct/merge snapshot 转为 Lime owner 的稳定 contract snapshot。 +2. 接通 `viewport` resize/reflow,并补 Codex 形状的 reconnect/focus/vt100 集成套件。 +3. 收敛 CLI partial:MCP OAuth contract、plugin edge cases、sandbox profile/network 矩阵、 + app-server entrypoint transport tests。 +4. 完成 npm 平台目标与 packaged Gate B;Cloud 只在 authenticated transport contract 完整后推进。 +5. 清理无消费者的顶层 `tui/src/reconnect.rs`,并继续用结构/治理守卫阻止 + `lime-cli`、`terminal-ui`、旧 runtime 和旧 launcher 命名回流。 diff --git a/internal/exec-plans/release-v1.142.0-plan.md b/internal/exec-plans/release-v1.142.0-plan.md new file mode 100644 index 000000000..b9ad8a0f5 --- /dev/null +++ b/internal/exec-plans/release-v1.142.0-plan.md @@ -0,0 +1,92 @@ +# Lime v1.142.0 发布执行计划 + +状态:进行中 +日期:2026-09-08 +目标版本:`1.142.0` +目标 tag:`v1.142.0` + +## 主目标 + +发布 v1.141.0 之后当前工作树中的 TUI/CLI Codex 对齐、App Server 执行进程与有界输出、 +认证 WebSocket transport foundation、Electron 安全凭证存储、Cloud App Server 配置解析、 +DeepSWE harness 环境注入及配套治理/测试改动;完成版本事实源、双语单页 release notes、 +质量门禁、release commit、tag、main 推送和远端复核。 + +## Release Candidate + +- `release metadata`:`package.json`、`packages/cli/package.json`、`lime-rs/Cargo.toml`、 + `lime-rs/Cargo.lock`、`RELEASE_NOTES.md`、`RELEASE_NOTES.en.md`、本计划及 + `internal/exec-plans/README.md`。 +- `candidate changes`:当前工作树全部已跟踪的产品、文档、测试、schema、依赖、脚本、 + Electron、App Server、Runtime、tool-runtime、CLI/TUI 与执行计划改动,以及除本地二进制外 + 全部未跟踪源码、测试、脚本和文档文件。 +- `excluded changes`:未跟踪的本机 Mach-O 构建产物 `rust_out`;不删除、不覆盖,保留在工作树。 + `.lime/`、`dist/`、`dist-electron/` 和 `lime-rs/target/` 为被忽略的本地生成目录,不进入提交。 + +## 架构确认 + +本轮仍遵循唯一业务主链: + +```text +Electron Desktop Host / CLI-TUI Host + -> App Server JSON-RPC + -> RuntimeCore + -> Thread / Turn / Item projection +``` + +Remote WebSocket 仅实现同一 App Server session 的认证 transport foundation;Electron 凭证 +存储只由 Desktop Host `safeStorage` owner 承担,不能读取 token 或创建第二套 runtime、会话状态、 +工具 registry 或持久化。执行进程输出继续由 `tool-runtime` 统一拥有,App Server 只负责生命周期与 +canonical projection。相关架构边界已在 `internal/aiprompts/architecture.md` 中同步;本轮没有 +新增平行业务后端。 + +## 退出条件 + +- 根应用、CLI npm 包、Rust workspace 与 Cargo.lock 统一为 `1.142.0`;双语 release notes + 只保留 v1.142.0;目标 tag 在写操作前不存在。 +- `npm run verify:app-version`、`npm run typecheck`、`npm run test:contracts`、受影响包/Rust + 定向测试、CLI/TUI Gate B、Agent Runtime current fixture、治理扫描和 `git diff --check` 通过。 +- `npm run verify:gui-smoke` 若环境允许则通过;无法执行时在收尾报告说明限制。 +- staged 内容覆盖全部 candidate changes 与 release metadata,仅排除 `rust_out` 及被忽略生成目录; + 完成 `Release v1.142.0` commit、`v1.142.0` tag,并推送 `origin/main` 与远端 tag 后复核。 + +## 验证记录 + +已完成: + +- `npm run verify:app-version`:通过(改版本前基线 `1.141.0`)。 +- `npm run typecheck`:首次被新增 remote `ws` 声明未纳入根级编译阻断;补充 + `packages/app-server-client/tsconfig.json` 的 `.d.ts` include 与 `remote.ts` reference 后通过。 +- `npm run test:contracts`:通过(协议类型、App Server client、命令、harness、modality、脚本、 + Electron release、CLI、docs boundary)。 +- `npm --prefix "packages/app-server-client" test`:11 个测试文件、139 项通过。 +- `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`:390/390 通过。 +- `npm run smoke:cli-gate-b`、`npm run smoke:tui-gate-b`:真实 CLI/TUI、stdio App Server、 + canonical projection、队列编辑与终端恢复通过。 +- `npm run smoke:agent-runtime-current-fixture`:通过,真实 Electron/preload/IPC/App Server/ + Runtime/read model、Claw、Coding Workbench、Skills、MCP、媒体、审批、恢复等 current fixture + 全部通过,`liveProviderUsed=false`。 +- `npm run verify:gui-smoke`:通过;真实 Electron/preload/IPC、App Server `appserver.v0`、Claw + workbench、响应式布局与 memory settings smoke 通过,app-server 版本为 `1.142.0`。 +- `npm run governance:legacy-report`:通过;结构/脚本/legacy 边界无违规。 +- `npx vitest run scripts/app-server/cli-gate-b.test.mjs scripts/app-server/tui-gate-b.test.mjs + scripts/app-server/tui-structure-inventory.test.mjs scripts/app-server/terminal-gate-binaries.test.mjs + scripts/harness/deepswe-adapter.test.mjs`:5 个测试文件、46 项通过。 +- `git diff --check`:通过。 + +环境限制:直接运行 `cargo test` 触发 `rusty_v8 v150.4.0` Darwin/aarch64 预构建 archive 404, +导致包含 V8 的 app-server/cli/lime-agent workspace 编译未完成;仓库 runner 已用于此前 current +fixture 和可运行的定向测试,不能把该本机 archive 失败误报为产品测试通过。Windows restricted +execution 真机矩阵、签名/公证和完整 Forge 产物由对应平台 runner 提供,本轮未在本机执行。 + +## 收尾分类 + +- `current`:TUI/CLI Codex-shaped owner、App Server execution process 与 command event mirror、 + bounded output、authenticated remote WebSocket foundation、Electron secure credential store、 + Cloud App Server parser、current fixture 与治理守卫。 +- `compat`:无新增;既有显式兼容导出不扩展。 +- `deprecated`:无新增。 +- `dead / deleted`:旧全局输出 FIFO、tail-only capture、未校验 sidecar data-dir、token URL/query + 传递、旧聚合 TUI 状态和禁止恢复的平行 runtime 入口。 + +当前完成度:版本文件与候选整理 95%;待用户确认后执行 release commit、tag、推送和远端复核。 diff --git a/internal/exec-plans/tui-cli-codex-sync-next-plan.md b/internal/exec-plans/tui-cli-codex-sync-next-plan.md new file mode 100644 index 000000000..43cc55351 --- /dev/null +++ b/internal/exec-plans/tui-cli-codex-sync-next-plan.md @@ -0,0 +1,263 @@ +# TUI/CLI 继续同步 Codex 执行计划 + +状态:planned +日期:2026-09-08 +参考实现:`/Users/coso/Documents/dev/rust/codex` +当前基线:Rust commit `cac96cd7b1756ab42e8925d938817a2ac10ebb6e` + +本计划承接 [全量差异报告](./codex-lime-tui-cli-difference-report.md),目标是继续按 +Codex 的真实目录、文件、公开类型、函数名和测试名同步 Lime 的 TUI/CLI。禁止根据截图 +或主观设计补造同名壳;每个实现批次必须先读取对应 Codex 源文件和测试,再迁移到 Lime +current owner。 + +## 1. 基线与完成目标 + +当前事实: + +| 维度 | Codex | Lime | 当前结论 | +| --- | ---: | ---: | --- | +| TUI Rust 文件 | 579 | 91 | 目录体系仍未同构 | +| TUI 类型/函数符号 | 12,000 | 1,491 | 结构差异大,须按 owner 分批收敛 | +| TUI snapshot | 802 | 0 | 已建立逐项分类账本,未迁入快照文件 | +| CLI Rust 文件 | 82 | 11 | current CLI 较薄,产品专属文件不机械复制 | +| execpolicy 文件 | 17 | 17 | 已同构 | +| CLI 测试 | 433 | - | 50 covered、88 partial、64 Cloud deferred、231 excluded | + +完成目标不是让文件数量形式上相等,而是: + +1. Codex current TUI 行为都有明确 Lime owner、测试和分类;可迁移的目录/符号/测试使用 + Codex 原名,不能继续由 Lime 聚合文件隐式承载。 +2. TUI 的 direct/merge 场景具备稳定的 Lime snapshot 或等价 TestBackend/VT100 断言; + contract 场景具备真实 App Server JSON-RPC evidence。 +3. CLI 的 88 个 partial 要么补齐 current contract 和真实 Gate B,要么记录明确的 + `defer`/`excluded` 理由,不保留 `missing/pending`。 +4. Desktop、CLI/TUI 继续共享 App Server、RuntimeCore、Thread/Turn/Item projection; + Cloud 只增加 authenticated transport,不复制 runtime、状态机、工具 registry 或存储。 + +## 2. 不变量与命名规则 + +- Codex 路径、模块名、`pub struct/enum/type/fn` 和测试名是基线;迁移前保存上游路径、 + source commit 和内容 hash。 +- `current` 只能落在 `lime-rs/crates/**`、`packages/cli` 或 App Server current owner; + `compat` 只能委托;`deprecated` 只能迁出;`dead` 必须删除并补回流守卫。 +- TUI 业务链固定为:`TUI Host -> app-server-client -> App Server JSON-RPC -> RuntimeCore -> canonical projection`。 +- 不复制 Codex 私有 auth、account、rollout/history DB、daemon、provider manager、 + marketplace、doctor、pets、theme、update、ChatGPT 状态或平台私有 sandbox internals。 +- 不恢复 `lime-cli`、`terminal-ui`、`TerminalGuard`、`TuiTerminal`、旧 bounded EOF + restart、旧 crossterm registry 输入路径;这些只能出现在 negative guard/history evidence。 +- 生产路径不使用 mock;fixture 只能用于测试,且必须通过真实 stdio/PTY/JSON-RPC 边界。 +- 新增代码不得使用 `codex*` 品牌前缀;只有与 Codex 对齐的领域 owner 名称可以保留 + `ChatWidget`、`HistoryCell`、`Renderable` 等语义名。 + +## 3. 写集与避让范围 + +### 本计划允许的写集 + +- `lime-rs/crates/tui/**` +- `lime-rs/crates/cli/**` +- `lime-rs/crates/app-server-client/**`、`lime-rs/crates/app-server-protocol/**`(仅为已确认的 + TUI/CLI contract 缺口) +- `packages/cli/**`、`pnpm-lock.yaml`、`lime-rs/Cargo.toml`、`lime-rs/Cargo.lock` +- `scripts/app-server/{tui,cli}-*.mjs` 及对应测试、TUI/CLI inventory 生成器和账本 +- `.gitignore`、`internal/exec-plans/README.md`、本计划和差异报告 + +### 明确避让 + +`electron/**`、无关 `src/**`、Codex Desktop/Goose 计划热区、服务端外部仓库、 +`lime-rs/crates/agent/**` 和已有未跟踪产物。若必须改变 App Server protocol,先单独登记 +contract 写集、消费者、schema、锁文件和测试,不在 TUI 批次夹写。 + +## 4. 阶段 A:TUI Codex owner 同构 + +### A0 迁移准备 + +- [ ] 为每个批次建立 `upstream-path -> lime-owner -> classification -> test` 映射,来源必须 + 是 Codex 当前 checkout,不得只依据文件名猜测。 +- [ ] 复核 `tui-structure-inventory.json` 和 `tui-codex-snapshot-inventory.json` 的 hash、 + source commit 与分类;新增 upstream 文件必须先分类再写代码。 +- [ ] 维护唯一 owner 表,确认 `projection.rs`、`runtime.rs`、`view.rs` 等 Lime 聚合 owner + 的拆分边界;不创建第二个 Thread/Turn/Item 模型。 + +### A1 纯终端 direct owner + +对应 Codex:`render/`、`streaming/`、`markdown_render/`、`diff_render`、`insert_history`、 +`terminal_hyperlinks`、`terminal_palette`、`table_detect`、`wrapping`。 + +- [ ] 按 Codex 文件名补齐 `tui/src/render/` 的 `mod.rs`、`line_utils.rs`、`renderable.rs`、 + `highlight.rs` 和 streaming highlight;把 Lime `highlight.rs`/`view.rs` 中对应纯算法迁入 + 唯一 owner。 +- [ ] 按 Codex 真实测试名迁移 48 个 `direct` snapshot 场景,使用 `insta` 或等价 + `TestBackend/Buffer` 断言;不引入 Codex runtime 状态。 +- [ ] 对 `markdown_render`、`diff_render`、`wrapping`、OSC 8 和宽字符边界补窄终端、UTF-8 + grapheme、URL、表格、重排回归。 +- [ ] 删除迁移后的 Lime-only 重复纯渲染函数,更新 inventory 并增加禁止重复 owner 的结构守卫。 + +退出条件:48 个 direct snapshot 全部有 Lime owner 和稳定断言;`cargo test -p tui`、 +TUI Clippy、结构 inventory 和 TUI Gate B 通过。 + +### A2 transcript/history owner + +对应 Codex:`history_cell/`、`exec_cell/`、`app/history_ui.rs`、`app/history_pagination.rs`、 +`app/transcript_export.rs`、`pager_overlay/`。 + +- [ ] 建立 `history_cell` 的 canonical entry adapter,只消费 App Server `Thread/Turn/Item` + projection;按 Codex 文件拆分 approvals、exec、MCP、patches、plans、messages、notices、 + session 和 request_user_input。 +- [ ] 建立 `exec_cell/{mod,model,live_output,render}.rs`,把 command live output 与 Lime + `entry.rs` 的重复渲染收回同一 owner。 +- [ ] 将 `Ctrl+T` transcript overlay、resume transcript、pager 和 export 统一到 + `history_cell`/`pager_overlay` 的渲染链;不创建 rollout/history DB。 +- [ ] 迁移 `merge=579` 中与 history/transcript/pager 相关场景;跨 runtime 的 80 个 + `contract` 场景必须绑定 App Server fixture 和 canonical identity。 + +退出条件:Message/Reasoning/Command/Patch/MCP/Plan/Multi-Agent/approval/request_user_input +各有稳定 cell owner;历史分页、overlay、export、live output 具备 VT100/PTY 证据。 + +### A3 composer/chatwidget/bottom_pane owner + +对应 Codex:`chatwidget/`、`bottom_pane/`、`public_widgets/composer_input.rs`、 +`keymap/`。 + +- [ ] 逐文件对照 Codex `chatwidget/{constructor,input_flow,input_submission,interaction, + interrupts,tool_lifecycle,turn_lifecycle,streaming,transcript,settings}.rs`,把 Lime + `app`、`bottom_pane/chat_composer` 和 `projection` 中相应逻辑迁入 Codex-shaped owner。 +- [ ] 按 Codex 目录补齐 composer 的 `attachment_state`、`draft_state`、`history_search`、 + `popup_state`、`slash_input`、`vim_history`、`vim_search`、`footer_state` 和测试;保留 + Lime 的图片/队列 canonical contract,不复制 Codex 私有 provider/auth。 +- [ ] 按 Codex `bottom_pane` 补齐 action banner、footer、selection、file search、skills、 + hooks、MCP elicitation 和 unified exec 的可用子集;没有 App Server consumer 的动态工具 + 和 MCP elicitation 继续 reject/fail closed。 +- [ ] 将 Lime `command_popup.rs`、`pending_input_preview.rs`、`status_indicator.rs`、 + `settings.rs` 迁移到对应 Codex owner,迁移后删除重复聚合实现。 + +退出条件:composer key/event/input/interrupt/queue/attachment/history 逐函数有 Codex 来源; +所有当前可用 popup、approval、request_user_input、status、queue 场景有测试和五语言文案。 + +### A4 app/startup/reconnect/resize owner + +对应 Codex:`app/{startup,startup_prompts,working_directory,session_picker,reconnect, +resize_reflow,thread_routing,thread_session_state,thread_title}.rs`、`tui/*`。 + +- [ ] 把 `viewport.rs` 接入 runtime resize/reflow 主路径,使用实际 Ratatui rendered rows, + 覆盖宽度变化、底部对齐、手动滚动和 alternate-screen round trip。 +- [ ] 迁移 Codex startup/session/working-directory 语义到 App Server contract;没有 Lime + current contract 的字段明确标为 `contract/defer`,不能在 TUI 本地合成。 +- [ ] 补齐 `tests/suite/reconnect.rs`、`resize_reflow.rs`、`status_indicator.rs`、 + `focus_palette.rs` 的 Lime 版本;顶层 `tui/src/reconnect.rs` 在零消费者后删除。 +- [ ] 把 terminal lifecycle 继续固定在 `Tui`/`Terminal`/`EventBroker`/`FrameRequester`, + 不恢复 `TerminalGuard` 或 runtime EOF 重启。 + +退出条件:TUI resize/reflow、focus、reconnect 和 terminal restore 通过真实 PTY/VT100; +旧 reconnect 壳和旧输入恢复路径不存在 current 引用。 + +## 5. 阶段 B:TUI 测试体系同构 + +- [ ] 新增 Codex-shaped `lime-rs/crates/tui/tests/`:`all.rs`、`test_backend.rs`、 + `manager_dependency_regression.rs`、`suite/mod.rs`、`suite/focus_palette.rs`、 + `suite/reconnect.rs`、`suite/resize_reflow.rs`、`suite/status_indicator.rs`、 + `suite/vt100_history.rs`、`suite/vt100_live_commit.rs`、`fixtures/oss-story.jsonl`。 +- [ ] 从 Codex 测试复制测试结构和测试名,仅替换 Lime App Server fixture、canonical + projection 和五语言文案;不得把 Codex 私有 backend/state DB 带入测试。 +- [ ] 引入 `insta`、`serial_test`、`assert_matches` 等依赖前先确认只用于 TUI test target, + 同步 Cargo.lock 和最小结构守卫。 +- [ ] 对 802 个 snapshot 保持逐项账本:`direct` 迁移、`merge` 合并、`contract` 绑定真实 + protocol、`defer` 保留退出条件、`dead` 禁止复制。 + +退出条件:TUI 集成目录、snapshot、TestBackend、VT100 history/live commit 和 PTY Gate B +同时通过;账本无未分类条目,且测试失败不会回退到 mock backend。 + +## 6. 阶段 C:CLI 88 个 partial 收口 + +### C1 CLI current contract + +- [ ] `permission options`:按 Codex `approve-for-me`、`not-so-yolo` 和 root/exec/resume + precedence 测试逐项决定是否能映射 Lime `permissionProfile/list` + `turn/start`;不能 + 映射的别名不添加假兼容。 +- [ ] `plugin`:补 marketplace cache、repo-local marketplace、catalog refresh、JSON + output 和 enable/disable 边界;所有 mutation 继续走 `plugin/*` App Server owner。 +- [ ] `mcp`:先补 `mcpServer/oauth/*` protocol、schema、client、handler 和 fixture,再 + 实现 login/logout;协议未完成前继续 fail closed,禁止 CLI 直接读 credential store。 +- [ ] `queue`:补本地 queue 的完整命令/错误矩阵;远程 queue 只在 Cloud transport evidence + 完成后启用,不添加本地 fallback。 +- [ ] `app-server entrypoint`:复制 Codex 参数和错误测试到 Lime sibling App Server owner; + transport/daemon/proxy/capability 只在有 current handler 时暴露。 +- [ ] `sandbox`:补 sandbox-state replay、managed network、named profile 矩阵;缺少 + App Server/tool-runtime owner 时保持 fail closed,不在 CLI 自建策略。 + +### C2 CLI 结构与命名 + +- [ ] 保持 `MultitoolCli`、`Subcommand`、`TuiCli`、`ExecCli`、`ResumeCommand`、 + `McpCli`、`PluginCli`、`FeaturesCli`、`QueueCommand`、`DebugCommand`、 + `SandboxSetupCommand`、`handle_exit_status` 等 Codex-shaped owner。 +- [ ] 将 `packages/cli/bin/lime.js` 的 `codexPackageRoot`、`findCodexExecutable`、 + `isPnpmOwnedCodexInstall` 改为 Lime 语义,并同步 npm package test 和 structure inventory。 +- [ ] 扩展 Linux ARM64/musl、Windows ARM64、Android 目标前必须先有可复现构建产物、 + native payload、launcher smoke 和 CI evidence;没有产物的 optional package 不得发布。 +- [ ] 保持 `init_firewall.sh`、`run_in_container.sh` 为 Codex Cloud/容器参考,不复制为 + Lime 伪生产入口。 + +退出条件:88 个 partial 全部变成 `covered`、有记录的 `defer` 或 `excluded`;CLI Gate B、 +npm staging/launcher、JSON/JSONL/stdin/exit/signal/completion 和结构 inventory 通过。 + +## 7. 阶段 D:Cloud transport foundation + +仅在阶段 A-C 的本地 current contract 稳定后推进: + +- [ ] 完成 authenticated WebSocket transport 的 tenant identity、protocol version、TLS、 + credential lifecycle 和 token redaction contract。 +- [ ] 补跨网络 disconnect/reconnect/resume、tenant isolation、rate limit、audit 和 + credential leak negative tests;TUI/CLI 继续复用同一 session facade。 +- [ ] 远程 queue、remote plugin catalog、Cloud managed permission profile、remote sandbox + 和 exec-server 测试仍对应 CLI ledger 的 64 个 `cloud-deferred` 条目。 +- [ ] 未取得 LimeCore 服务端合同、租户隔离证明和真实远端 Gate B 前,不接入默认 CLI/TUI、 + Electron sidecar 或 npm production package,不创建假 Cloud endpoint。 + +退出条件:安全评审、服务端合同、真实 authenticated remote Gate B 和审计 evidence 全部具备。 + +## 8. 治理与删除 + +- [ ] 每个批次刷新 TUI/CLI structure、snapshot、test inventory,并把新增差异分类为 + `current/compat/deprecated/dead`。 +- [ ] 零消费者后删除顶层 `tui/src/reconnect.rs`;旧路径只保留 retired guard/negative test。 +- [ ] 对 `lime-cli`、`terminal-ui`、`TerminalGuard`、`TuiTerminal`、旧 runtime、旧 + crossterm registry、旧 launcher 命名增加结构负向测试。 +- [ ] 若发现跨命令组 legacy policy/mock residual,登记 `tech-debt-tracker.md` 的 `CCD-012`, + 不在本计划中新增兼容层。 + +## 9. 验证门禁 + +每个阶段按最小边界验证,阶段收尾再扩大: + +```bash +cargo test --locked -p tui -p cli +cargo clippy --locked -p tui --no-deps -- -D warnings +cargo clippy --locked -p cli --no-deps -- -D warnings +npx vitest run scripts/app-server/tui-structure-inventory.test.mjs +npx vitest run scripts/app-server/tui-snapshot-inventory.test.mjs +npm run test:contracts +npm run smoke:cli-gate-b +npm run smoke:tui-gate-b +npm run governance:legacy-report +npm --prefix packages/cli test +git diff --check +``` + +涉及协议/锁文件时追加 `cargo metadata --locked`、相关 App Server crate 测试和 +`npm run test:rust:related -- `;涉及真实 TUI 生命周期时必须追加 PTY、alternate +screen、键盘输入和终端恢复证据;涉及 Cloud 时必须追加服务端合同和 authenticated remote Gate B。 + +## 10. 完成定义 + +- TUI structure inventory 中所有 Codex current 能力均有 Lime current owner;聚合 Lime-only + owner 已拆除或有明确保留理由。 +- 802 个 snapshot 全部有 `direct/merge/contract/defer/dead` 分类,direct/merge/contract + 的可交付子集均有对应测试和证据,dead 不进入 current。 +- TUI Codex-shaped integration suite 全绿;390 个现有 TUI 测试不回退。 +- CLI ledger 不再有 `missing/pending`;partial 逐项收口或有可验证 defer/excluded 记录。 +- Desktop 与 CLI/TUI 仍消费同一个 App Server JSON-RPC/RuntimeCore/read model;Cloud 仍是 + authenticated transport 扩展点。 +- `lime-cli`、`terminal-ui`、旧 runtime、旧 launcher 和第二套状态机无 current 引用,治理 + 扫描通过。 + +下一执行批次:**A0 + A1(纯终端 direct owner 与 48 个 direct snapshot)**。完成后再进入 +A2 transcript/history,不并行扩张 Cloud 或复制 Codex 产品专属模块。 diff --git a/internal/exec-plans/tui-cli-surfaces-plan.md b/internal/exec-plans/tui-cli-surfaces-plan.md index 138799b7a..99e57bcaf 100644 --- a/internal/exec-plans/tui-cli-surfaces-plan.md +++ b/internal/exec-plans/tui-cli-surfaces-plan.md @@ -187,6 +187,9 @@ Lime 与 LimeCore 只共享 App Server protocol/session 合同;租户隔离、 - `ClientSession::start_remote`、TUI `AppServerSession::connect_remote` 与 CLI `start_session` 复用同一 initialize/request/event/shutdown actor;TUI reconnect 和 session picker 也按同一 remote 配置工作,未创建第二套 runtime、Thread 状态或持久化。 - CLI 参数与 Codex 形状对齐:`--remote URL`、`--remote-auth-token-env ENV_VAR`;缺失 endpoint、缺失/空 token 环境变量均在连接前失败。 - `cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server-client -p cli -p tui`:44 + 34 + 177 全部通过;新增 remote auth policy、WebSocket roundtrip、CLI parser 和 fail-closed 回归。 +- TypeScript `packages/app-server-client/src/remote.ts` 复用既有 `AppServerConnection`,补齐 Node/Electron 可用的 authenticated WebSocket transport;Bearer token 与 tenant id 仅进入 header,握手在 `initialized` 前固定校验 `app-server`/`appserver.v0`,并复用 JSON-RPC request、notification、reverse request、cancel 和单读泵语义。 +- TypeScript remote transport 只作为 Cloud foundation,不接入 renderer 或默认 Electron sidecar;生产 Cloud endpoint 仍由 Cloud 服务的租户、凭证、恢复、限流和审计合同决定。 +- `npm --prefix "packages/app-server-client" test`:11 个测试文件、139 项通过,包含 6 项真实 `ws` `WebSocketServer` roundtrip/header/握手/身份/协议安全回归,以及 browser entry 不暴露 Node/WebSocket transport 的边界回归;`npm run check:protocol-types`、Node syntax、Prettier 与 `git diff --check` 通过。 - P4 剩余未完成:identity、tenant isolation、凭证存储、协议版本协商、跨网络 resume/rate limit/audit 与真实 Cloud endpoint;在这些条件完成前,Cloud 继续保持架构扩展点,不进入生产发布路径。 - `npx vitest run scripts/app-server/{cli,tui}-gate-b.test.mjs scripts/app-server/tui-snapshot-inventory.test.mjs`:7 项通过;包含 Windows CLI/TUI workflow 结构守卫。 - `cargo clippy -p tui -p cli --no-deps -- -D warnings`:通过。 @@ -585,7 +588,7 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event` - `bottom_pane/request_user_input` 迁移为 Codex 同名目录模块,并新增 `render.rs`,将问题渲染与 `bottom_pane/render.rs` 的 approval 渲染职责分离。`bottom_pane/chat_composer.rs` 现为 composer 唯一 owner,类型名为 `ChatComposer`;终端生命周期 owner 已从 Lime-only `terminal.rs` 迁到 - Codex 同名 `tui.rs`,`TerminalGuard` 仍保留其真实生命周期职责。 + Codex 同名 `tui.rs`;终端生命周期类型已进一步收敛为 Codex 同名 `Tui`。 - 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(188/188)、 `cargo fmt --manifest-path "lime-rs/Cargo.toml" --all`、 `npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs" "scripts/app-server/tui-snapshot-inventory.test.mjs"` @@ -602,19 +605,19 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event` `tui/frame_requester.rs`。`EventBroker` 是唯一 crossterm 输入 owner,支持暂停/恢复并在 `TuiEventStream` 中统一 key、paste、resize、focus 与 draw;`FrameRequester` 合并异步重绘 并限制最高 120 FPS。 -- `TerminalGuard` 现在持有 broker、draw channel 和 frame requester;外部编辑器 suspend/resume +- `Tui` 现在持有 broker、draw channel 和 frame requester;外部编辑器 `with_restored` 会释放并恢复 stdin。runtime 与 session picker 均消费 `TuiEventStream`,不再各自直接创建 `EventStream`,业务事件仍由 App Server session 与 `App` 处理。 - 新增 Codex 同名事件流/帧调度回归 21 项,`cargo test -p tui` 当前为 214/214;结构账本刷新为 624 文件,TUI/CLI structure、snapshot、test inventory 11/11,`cargo clippy -p tui -p cli --no-deps -- -D warnings` 与 workspace fmt 通过。 -- 分类:`current = tui::event_stream + tui::frame_requester + TerminalGuard`; +- 分类:`current = tui::event_stream + tui::frame_requester + Tui`; `compat/deprecated = none`;Codex job-control、inline scrollback、keyboard enhancement 与 platform probe 仍按账本 `defer/product-specific`,本轮不伪造第二套 runtime 或平台 owner。 ## 2026-09-05 PTY editor handoff recovery -- 外部编辑器返回后的 `TerminalGuard::resume` 只重新启用 raw/alternate/bracketed-paste 模式并恢复 +- 外部编辑器返回后的 `Tui::with_restored` 重新启用 raw/alternate/bracketed-paste 模式并恢复 `EventBroker`,不再调用会同步发送 `CSI 6n` 的 Ratatui 清屏路径;下一轮正常 draw 负责重绘恢复后的 composer。这样不会让 TUI 的事件 reader 与编辑器/PTY 的光标响应竞争 stdin。 - 删除 Gate B 中针对旧同步光标查询的 PTY 回写步骤,改为直接断言编辑器交接标记和恢复后的 prompt; @@ -625,7 +628,7 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event` `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui -p cli --no-deps -- -D warnings`、 `npm run smoke:tui-gate-b`(complete、approval、user-input、interrupt、failure、queue-edit 六场景) 全部通过。 -- 分类:`current = tui::tui::TerminalGuard + tui::runtime_pty_tests`; +- 分类:`current = tui::Tui + tui::runtime_pty_tests`; `compat/deprecated = none`;旧同步 CPR 查询与调试输出为 `dead / deleted / forbidden-to-restore`。 ## 2026-09-05 Codex text formatting owner @@ -689,3 +692,522 @@ Codex 形状的 `handle_key_event(KeyEvent)`。request-user-input 的 `Event` resume_picker_transcript_preview_tests.rs`;`dead/deleted/forbidden-to-restore = session_picker.rs`; Codex 的本地 state DB、归档恢复后台 loader、远端跨网络 resume 和完整密集表格 UI 仍为 `deferred`,不能以本地伪实现替代 App Server owner。 + +## 2026-09-06 Codex resume picker pager and incremental pagination + +- `resume_picker.rs` 新增 `PickerTranscriptPager`,复用 current `PagerOverlay`、 + `entry::hyperlink_lines_with_locale` 和 canonical `TranscriptEntry`。Ctrl+T 在选中会话上打开 + 独立 transcript pager,加载中/失败/空记录均可见;Esc、Q、Ctrl+T 关闭,Home/End、上下键和 + PageUp/PageDown 沿用 pager 导航,不把 picker transcript 复制成第二个 projection owner。 +- `thread/list` 改为单页 `ThreadPage` 请求;`PickerState` 复用 Codex 形状的 + `PaginationState`/`PageCursor`/`PageLoadMode` 保存 cursor 和 request token,仅在列表尾部滚动时 + 追加下一页,按 thread id 去重,查询、状态、目录、排序变化会失效旧 cursor,重复 cursor 和 + stale response fail closed。 +- 归档成功会清理对应 transcript pager;footer 与 transcript 状态文案覆盖五个产品 locale。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(288/288)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + `npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(3/3)、 + `npm run test:contracts`、`npm run governance:legacy-report`、`npm run smoke:tui-gate-b`(六场景) + 与 `git diff --check` 均通过。 +- 分类:新增 pager、分页状态、locale 和测试属于 `current`;没有新增 `compat` 或 + `deprecated`;旧 `lime-cli`、`terminal-ui`、Codex 私有 history/state DB 仍为 + `dead/deleted/forbidden-to-restore`。剩余差距回到 P3 composer 分模块迁移和 Codex snapshot + 场景选择性迁移,不复制无 Lime owner 的产品模块。 + +## 2026-09-06 Codex composer textarea owner + +- 迁移到 Codex 同名 `bottom_pane/textarea.rs`,`TextArea` 统一持有可编辑文本和 + UTF-8/grapheme 安全光标;`ChatComposer` 保留提交、排队、历史搜索和权限/编辑器动作编排, + 不再直接维护第二套文本/光标状态。 +- 迁移 `insert`、左右移动、行首尾、前后 grapheme 删除和整段替换;保留现有 + `InputResult` 与 App Server queue/turn lowering,不引入 Codex 私有 Vim、mention catalog 或 + attachment runtime。 +- 新增 `TextArea` 同名语义测试,覆盖复合 emoji 删除和多行行首尾;验证: + `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(290/290)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + `npm run smoke:tui-gate-b`(六场景)和 `git diff --check` 通过。 +- 分类:`TextArea` 与 `ChatComposer` 为 `current`;未新增 `compat/deprecated`,未恢复 + Codex 私有 history DB、Vim/mention/connector 产品面。下一步继续从 snapshot inventory 选择 + 可由 Lime current owner 承接的 composer/history 场景。 + +## 2026-09-06 Codex composer history search owner + +- 新增 Codex 同名 `bottom_pane/chat_composer/history_search.rs`,由 + `HistorySearchState`、`find_match`、`find_older`、`find_newer` 承接纯历史匹配规则; + `ChatComposer` 只编排查询输入、draft 恢复、选中结果和提交,不再内嵌搜索算法副本。 +- 补充大小写不敏感、旧/新方向边界、空查询和无选中项回归;未改变 App Server + `promptHistory/read|append` 或队列/turn lowering。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib bottom_pane::chat_composer` + (13/13)与 `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings` + 通过。分类:`history_search.rs` 与 `ChatComposer` 为 `current`,无 `compat/deprecated`, + Codex 私有 rollout/history DB 仍为 `dead/deleted/forbidden-to-restore`。 + +## 2026-09-06 Codex composer state owners and textarea wrapping + +- `ChatComposer` 现在按 Codex 目录拆出 `chat_composer/attachment_state.rs` 与 + `chat_composer/draft_state.rs`。`AttachmentState` 成为本地图片队列的唯一 owner, + `DraftState` 持有 `TextArea` 和待恢复 draft;`App` 不再维护平行 `pending_images` 字段, + runtime/view/test 通过 composer 委托保持原有 API 和 App Server lowering。 +- `bottom_pane/textarea/wrapping.rs` 按 Codex 同名纯终端算法迁入 grapheme-safe + `wrapped_lines`、`cursor_position` 与 `visible_prefix`,`TextArea` 光标定位复用该结果, + 支持软空格挂接、非断行空格、中文/emoji UTF-8 边界和满行 cursor sentinel;不引入 Vim、 + mention catalog、history DB 或第二套 runtime 状态。 +- 新增 `bottom_pane/textarea/wrapping_tests.rs` 与 `attachment_state` 单测,覆盖换行边界、 + 显示宽度、附件有序取回/恢复/删除;结构账本刷新后保留 Codex 路径、函数和类型双向对照。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(296/296)、 + `npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(3/3)通过; + 后续收尾仍需执行 clippy、相关 Rust 测试、contracts、治理扫描、TUI Gate B 和 diff check。 +- 分类:`bottom_pane/textarea.rs`、`bottom_pane/textarea/wrapping.rs`、 + `chat_composer/{attachment_state,draft_state}.rs` 与委托调用属于 `current`;没有新增 + `compat/deprecated`;Codex 私有 Vim/mention/connector/history DB 继续为 + `dead/deleted/forbidden-to-restore`。 + +## 2026-09-06 Codex textarea state and reconnect boundary + +- `bottom_pane/textarea.rs` 补齐 Codex 同名 `TextAreaState`、`cursor_pos`、 + `cursor_pos_with_state`、`desired_height`、`insert_str`、`replace_range`、`set_cursor` 和 + Ratatui `StatefulWidgetRef` 渲染接口;主 composer 通过 `DraftState::textarea_state_mut` 保存 + viewport scroll,长行和多行输入按实际视觉行滚动,光标始终留在可见区域。 +- 新增 `bottom_pane/chat_composer/reconnect.rs` 与 `reconnect_tests.rs`,提供 Codex 形状的 + `ChatComposer::handle_disconnected_key`:断线编辑会恢复 history preview,Enter/Tab 不会 + 消费草稿,字符与基本移动/删除仍保持 UTF-8 安全;重连尝试和 session 生命周期仍由 + `tui::reconnect` + App Server owner 承担。 +- 结构账本扫描器改为识别缩进的 `impl` 方法,避免只记录顶层函数造成 Codex 符号漏报;账本 + 新增 reconnect 文件与 `TextAreaState`/cursor state 符号守卫。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(301/301;含 stateful + render 清屏回归)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + `rustfmt --check`(本轮 touched TUI 文件)、结构账本 Vitest(3/3)和 `git diff --check` + 通过;workspace 全量 `cargo fmt --check` 仍受既有并行改动阻塞,未执行全仓格式化。 +- 分类:`TextAreaState`、stateful composer render、reconnect boundary 与对应测试属于 + `current`;没有新增 `compat/deprecated`,Codex 私有 Vim/mention/connector/history DB + 继续为 `dead/deleted/forbidden-to-restore`。下一步回到 snapshot inventory,选择能由 Lime + current owner 承接的 composer attachment/draft snapshot 和 multi-agent navigation 场景。 + +## 2026-09-06 Codex composer key-event boundary + +- 对照 Codex `ChatComposer::handle_key_event` 的入口语义,公共 composer 现在先忽略 + `KeyEventKind::Release`,只让 Press/Repeat 进入历史搜索、编辑、提交和排队分派;主循环已有 + 的 Press 过滤保持不变,断线入口继续由 `handle_disconnected_key` 独立处理。 +- 新增 `key_release_does_not_insert_or_submit` 回归,证明释放事件不会改变草稿、触发提交或 + 消费当前输入;没有改变 `TextArea::input`、App Server queue/turn lowering 或 Ctrl-C 边界。 +- 验证:touched `chat_composer.rs` `rustfmt --check`、 + `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib bottom_pane::chat_composer` + (18/18)、`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings` + 均通过;随后完整 TUI、结构 inventory、contracts、治理扫描与真实 TUI Gate B 作为收尾门禁。 +- 分类:`current = ChatComposer::handle_key_event` 的 Press/Repeat 边界及其测试; + `compat/deprecated = none`;Codex 私有 Vim/mention/connector/history DB 仍为 + `dead/deleted/forbidden-to-restore`。本轮继续推进同一 `CLI/TUI Host -> App Server JSON-RPC` + 主链,不复制第二套 runtime 或历史状态 owner。 + +### 收尾门禁 + +- 完整 `cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`:318/318 通过。 +- `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings` 与 + touched 文件 `rustfmt --check` 通过。 +- TUI structure inventory 与 802 条 Codex snapshot inventory:5/5 测试通过; + `npm run test:contracts`、`npm run governance:legacy-report`、`git diff --check` 通过。 +- `npm run smoke:tui-gate-b`:complete、approval、user-input、interrupt、failure、queue-edit + 六场景通过,真实 PTY/alternate screen/键盘输入/终端恢复与 canonical App Server ledger 均通过。 + +## 2026-09-06 Codex model catalog and collaboration mode wiring + +- 按 Codex `tui/src/model_catalog.rs` 保留 `ModelCatalog`、`with_collaboration_modes` 和 + `try_list_models`,模型 picker 通过该 catalog 读取服务端模型;移除当前 Lime 没有真实消费者的 + `model_by_id`/`default_model` 扩展,避免用测试 API 冒充产品能力。 +- 按 Codex `tui/src/collaboration_modes.rs` 接入 `default_mask`、`default_mode_mask`、`next_mask` + 和 `plan_mask`:启动/设置同步优先服务端 Default preset,缺失时才回退到服务端首个可见 preset; + `Shift+Tab` 仅在无 active turn、无 modal/popup 时循环服务端模式,结果通过现有 + `thread/settings/update` lowering,不创建 TUI 私有模式状态源。 +- `AppServerSession::start_thread` 现在返回完整 typed `ThreadStartResponse`。TUI 使用服务端实际 + model/provider/reasoning effort 初始化本地只读状态,因此未传 `--model/--provider` 的自动模型场景 + 也能构造合法 collaboration mode;`exec` 入口同步适配该 typed 返回值。 +- 新增 App 回归覆盖默认模式选择、`BackTab` 模式循环和运行中不抢占输入;模式更新失败保持当前 + 本地状态,服务端未提供 mode catalog 时 `/plan` 继续 fail closed。 + +本轮验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(324/324)、 +`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 +`node scripts/app-server/tui-structure-inventory.mjs`(646 files)及 +`npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs" "scripts/app-server/tui-snapshot-inventory.test.mjs"` +(5/5)通过。`cargo fmt --check` 未作为全仓绿灯报告:当前并行工作树已有未归本轮的大量格式差异, +本轮未执行全仓格式化;新增/修改逻辑由 Clippy、编译和单测覆盖,`git diff --check` 待本轮收尾门禁执行。 + +分类:`current = tui::model_catalog + tui::collaboration_modes + AppServerSession typed thread/start + +App mode cycling`;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 未消费的 +model catalog 查找扩展、旧 lime-cli、terminal-ui、Codex 私有 history/state DB`。下一刀继续从 +snapshot inventory 选择 multi-agent navigation 或剩余可由 Lime current owner 承接的模式/模型场景, +不复制 Codex 私有 runtime、账号和 Cloud 状态。 + +## 2026-09-06 Codex collaboration mode status localization and effort clearing + +- 为 `plan mode`、`plan mode unavailable on this server` 和 `collaboration mode updated` 补齐 + `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 状态映射,并加入逐语言回归;未知状态仍保持 + 原文,状态翻译继续由 `Locale::status` 作为唯一展示边界承接。 +- 模式切换后 runtime 直接采用服务端 `CollaborationModeSettings.reasoning_effort`,不再用旧 + effort 值覆盖 `Some(None)` 的显式清除;保留 mask 缺省值通过 lowering 时的 fallback 语义。 +- 更新 App 模式循环测试,覆盖 Default preset 的显式 effort 清除;没有新增 compat/deprecated + 路径,也没有复制 Codex runtime、history DB 或 Cloud 状态。 + +本轮验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(325/325)、 +`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、touched +文件 `rustfmt --check`、`git diff --check`、`npm run test:contracts`(299 项 client contract)、 +`npm run governance:legacy-report`(零引用候选/分类漂移/边界违规)、 +`npm run governance:cli-boundary`、TUI structure/Gate B Vitest(6/6)及 +`npm run smoke:tui-gate-b`(六场景真实 PTY、alternate screen、App Server ledger、终端恢复) +均通过。全仓 `cargo fmt --check` 仍受并行工作树既有格式差异影响,未执行全仓格式化。 + +分类:`current = Locale::status` 的协作模式状态、runtime collaboration mode lowering 与 +对应测试;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、 +terminal-ui、Codex 私有 runtime/history/state DB`。下一刀继续从 Codex snapshot inventory 选择 +具备 Lime current owner 和真实证据的 multi-agent navigation 或剩余模式/模型交互场景。 + +## 2026-09-06 Codex settings synchronization boundary + +- 对齐 Codex `chatwidget/settings.rs` 的有效状态语义:`App::set_settings` 在 model 有显式值时 + 同步当前 collaboration mode 的 model;reasoning effort 只有显式提供时才覆盖当前 mode,避免 + CLI 未传 `--effort` 时把服务端 preset 默认值误清空。 +- collaboration mode 切换仍直接把服务端 mode settings 写入 `App`,因此 `Some(None)` 的显式 + effort 清除不会被全局 settings fallback 重新覆盖。 +- 新增 `settings_updates_keep_the_active_collaboration_mode_in_sync`,同时覆盖 model 更新、显式 + effort 更新和未提供 effort 时保留 preset;未新增 compat/deprecated 路径。 + +本轮增量验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(326/326)、 +`cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、touched +TUI 文件 `rustfmt --check` 均通过;前一节记录的 contracts、governance、结构快照和真实 +TUI Gate B 证据继续有效。 + +分类:`current = App::set_settings` 与 collaboration mode 有效状态同步; +`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、 +Codex 私有 runtime/history/state DB`。下一刀继续从 multi-agent contract snapshots 选择能由 +Lime App Server current owner 承接的纯展示/导航能力。 + +## 2026-09-06 Codex multi-agent interaction regression boundary + +- 对照 Codex `app/agent_navigation.rs` 的 parent-owned/liveness 语义,`SubAgentActivity::Started` + 现在在 parent stream 观察到时立即标记 child thread 为 parent-owned;即使 `ThreadStarted`、 + `ItemStarted` 和 resume 的到达顺序交错,也不会向子 Agent 直接提交或排队用户输入。 +- `App::can_accept_direct_input` 成为 TUI runtime 的唯一直接输入护栏,`Submit` 与 `Queue` 共用 + 同一检查和 canonical 状态文案,移除 runtime 内的重复判断。 +- `/subagents` 执行回归确认 slash command 会消费本地命令、清空 composer 并打开 Codex 命名的 + Agent picker;新增五产品 locale 的 picker title/footer TestBackend 渲染回归。footer 改为 + 不在单行区域叠加底边框,避免窄终端裁掉可见控制文案;新增当前 Agent footer 渲染回归。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(347/347)通过, + touched TUI 文件 `rustfmt` 与 `git diff --check` 通过。新增行为只复用 App Server + `Thread`/`SubAgentActivity`/`thread/resume` 和 canonical `ConversationProjection`,没有新增 + runtime、持久化或 Cloud 假 endpoint。 + +分类:`current = app::{agent_navigation,agent_picker} + multi_agents + App direct-input guard` +及其回归;`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、 +terminal-ui、Codex 私有 agents daemon/state DB`。完整 contracts、governance、TUI Gate B +收尾门禁仍需在本轮最终状态上复跑。 + +## 2026-09-06 Codex Agents Overview owner alignment + +- 按 Codex 真实目录补齐 `app/agents_overview.rs`、`app/agents_overview_render.rs`、 + `app/agents_overview_threads.rs`、`app/agents_overview_view.rs` 与独立 + `app/agents_overview_tests.rs`。Overview 的状态分组、根线程/子线程状态冒泡、搜索、分组切换、 + 刷新、选择和窄终端渲染均由同名 current owner 承接。 +- `/agents` 现在打开 Agents Overview,并返回显式 `RefreshAgentsOverview` action;`/subagents` + 保留为当前会话 Agent Picker。运行时通过 `AppServerSession::list_thread_page` 分页读取 + canonical v2 `ThreadList`,不访问 Codex 私有 daemon/state DB。ThreadStarted/Closed/ + StatusChanged/NameUpdated/Archived/Deleted 通知在 `App::apply_notification` 边界更新 Overview, + 刷新代次用于丢弃过期结果。 +- 新增五种产品 locale 的 Overview 标题、控制提示、搜索前缀和状态摘要;`agent_picker` 继续 + 作为 `/subagents` 的 current 会话导航 surface,Overview 可见时不渲染或接收其输入。 +- Codex 的 shared agents daemon 生命周期、后台线程 turn owner 与完整输入状态恢复尚未迁入; + Lime 已将新任务、改名、停止和交互请求重放接到现有 App Server `thread/start`、`turn/start`、 + `thread/name/set`、`turn/interrupt` 与当前 `BottomPane`,但 daemon 专属的跨会话语义仍分类为 + `deferred`,本轮不伪造 endpoint、request 或平行 runtime。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(354/354)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + `node scripts/app-server/tui-structure-inventory.mjs`(Codex/Lime 文件缺口为 0)、 + touched TUI `rustfmt` 与 `git diff --check` 通过。 + +分类:`current = app/agents_overview* + AppServer ThreadList/notification projection + +RefreshAgentsOverview action + existing child-thread command lowering + app/agent_picker` +(`/subagents` current 会话导航);`compat/deprecated = none`; +`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 agents daemon/state DB`; +`deferred = Codex daemon 专属跨会话 dispatch/stop/replay 语义`。下一刀应补真实 App Server +child-thread command contract 后,再迁移 daemon 级跨会话恢复,不得先恢复 Codex 私有 daemon。 + +## 2026-09-06 Agents Overview refresh state and resume command + +- `AgentsOverviewState` 补齐 Codex 同名生命周期字段:`initialized`、`request_id`、 + `refresh_pending`、`refresh_thread_ids`、`refresh_notifications`、`refresh_task`、 + `visible_thread_ids`、`rendered_full_screen` 和共享 `view_state` 镜像。当前 `Vec` 仍是 + Lime 的唯一投影 owner;共享镜像只服务于同名 host adapter,不创建第二套交互状态。 +- 刷新请求现在具备单一 request identity;并发刷新被合并,刷新期间同一线程同类通知采用 + last-write-wins,响应完成后按到达顺序重放。Overview 的选中行、过滤结果和共享 view state + 同步维护,状态对象 drop 时取消已安装的异步 refresh task。 +- 新增 Codex 风格 `/resume` slash command,直接触发现有运行中 resume picker;命令目录、五语言 + 描述、设置命令解析和 App 回归同步更新。`agent_picker` 由 `/subagents` 触发并保持 current, + 不参与 Overview 产品渲染。 +- 验证:Agents Overview 和 slash command 定向测试通过;完整 TUI 测试与 clippy 待本轮收尾门禁 + 重跑。真实 PTY 继续覆盖既有六场景;Overview -> picker 的 PTY 证据仍待终端控制键在 + portable-pty 环境稳定映射后补齐。 + +分类:`current = AgentsOverviewState request/notification lifecycle、/resume、/agents、/subagents、 +既有 App Server thread/list + thread/resume`;`compat/deprecated = none`; +`dead/deleted/forbidden-to-restore = lime-cli、terminal-ui、Codex agents daemon/state DB`; +`deferred = 依赖真实 App Server child-thread command contract 的跨会话 daemon 行为`。下一刀优先补 +App Server child-thread contract 和 portable-pty 可重复的 Overview 流程,再扩展完整 Codex +`BottomPaneView`,不恢复私有 daemon。 + +## 2026-09-06 Agents Overview thread targeting and PTY Gate B + +- 按 Codex 真实目录新增 `app/app_server_event_targets.rs`,保留同名 + `ServerNotificationThreadTarget`、`server_notification_thread_target` 与 + `server_request_thread_id`。`App::apply_notification` 先更新 Overview/导航,再只把当前 Thread + notification 写入当前 `ConversationProjection`;后台 Thread 的 message/item/turn 事件不会污染 + 前台 transcript。 +- foreign Thread reverse request 的分发复制 Codex `unsupported_request` 处理形状。只有 Lime + `BottomPane` 已实现的 command/file/permission approval 与 `request_user_input` 可以进入 Overview + 对应 `ThreadEventStore` 的 bounded replay channel;`pending_interactive_replay` 维护 request identity,Dynamic Tool 和 MCP elicitation 当前没有 TUI consumer,立即走既有 reject,channel 满时也 fail closed + 路径,不会在恢复时静默丢失。`unsupported_requests_return_for_rejection` 锁住该 fail-closed 合同。 +- `agents-overview` 成为默认第七个 TUI Gate B 场景:真实 `/agents -> Ctrl+N -> prompt -> Enter` + 经过 `thread/start + turn/start`,等待 `ThreadStatusChanged(Active)` 投影为 `1 working` 后, + 真实发送 Down、Ctrl+R、Ctrl+X、Enter 与 Ctrl+D,依次验证选择后台 Thread、改名、停止同一 + Turn、恢复 transcript 和退出 TUI。ledger 断言精确 input、Thread/Turn identity、 + `turn.started,message.delta` 与同 identity `turnCancel`;PTY 在关闭 alternate screen 前使用 Codex + 同款 `vt100::Parser` 还原 24x100 实际屏幕并验证终端恢复。 +- 共享 terminal backend fixture 对所有 `turnStart` 统一先发 Codex 生命周期事件 + `turn.started`,修复“输出可见但 Thread 永远 Ready”的伪闭环;七个场景各自使用独立 + cwd/data/app-data,避免前序 Thread 改变 Overview 选择顺序。`terminal-gate-binaries.mjs` 在默认 + 路径下用仓库受校验 rusty-v8 环境重建 current `cli`/`app-server`,显式二进制覆盖则保持不变, + 因而 Gate 不再测试新 harness 却复用旧产品二进制。 +- 按 Codex `key_hint.rs::is_plain_text_key_event` 的同名边界接入 Overview 新任务、搜索与改名输入, + 允许 Shift 字符并拒绝 Control/Alt 文本,真实 PTY 中 `Gate B background` 不再丢失大写字符。 + 用户指定的 GPT-6 viewport workstream 同时落地 `viewport.rs`,字段与状态语义映射 Codex + `custom_terminal.rs`/`tui.rs` 的 `viewport_area`、`last_known_screen_size`、 + `last_known_cursor_pos`、`alt_saved_viewport`、resize 与 alternate-screen round trip;它不创建第二 renderer。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui --lib`(373/373)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + terminal Gate/构建 helper Vitest(8/8)、structure/snapshot inventory(5/5)、 + `npm run smoke:tui-gate-b`(七场景,`queue-edit=ok agents-overview=ok terminal=restored`)、 + `npm run smoke:cli-gate-b`、`npm run test:contracts`、`npm run governance:legacy-report` 和 + structure inventory(657 files)通过;CLI boundary 为 + `current=cli+tui+npm-platform-packages retired=lime-cli`。全 crate + `cargo fmt --check` 仍报告并行工作树中既有的 TUI import/折行差异,本轮没有批量格式化或覆盖。 + +分类:`current = app/app_server_event_targets.rs + 当前 Thread projection 路由 + Overview +ThreadEventStore + pending_interactive_replay + 七场景 TUI Gate B + app/agent_picker(`/subagents` 会话导航); +`deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 +runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产 Cloud`。本刀已用真实 stdio/PTY +覆盖 child-thread start/name/interrupt/resume;下一刀继续从 Codex TUI 测试账本选择能由 Lime +App Server current owner 承接的未覆盖交互,不恢复私有 daemon。 + +## 2026-09-06 Codex App Server event owner alignment + +- 按 Codex 真实目录新增 `tui/src/app/app_server_events.rs`,由同名 + `handle_app_server_event` 与 `handle_server_notification_event` 承接 TUI transport event 和 typed + notification;reverse server request 由同名 `tui/src/app/app_server_requests.rs::handle_server_request_event` + 承接。交互 `runtime.rs` 不再直接匹配 notification/server request,只保留 terminal + lifecycle、session shutdown 与 bounded reconnect 编排。 +- `app-server-client::AppServerEvent` 按 Codex 定义迁到 `app-server-client/src/lib.rs`,并精确收敛为 + `Lagged`、`ServerNotification`、`ServerRequest`、`Disconnected` 四个变体。旧 Lime-only + `SessionEvent`、`RawNotification` 和 `RawServerRequest` 类型形状已删除,没有 compat alias。 +- 未知 notification 在 typed client 边界忽略;未知 reverse request 在同一 transport 上立即返回 + JSON-RPC `METHOD_NOT_FOUND`,不再泄漏给 CLI/TUI。复制 Codex 测试名 + `remote_unknown_server_request_is_rejected`,覆盖 request id、错误码、错误文案和拒绝后的正常 + shutdown。 +- 结构 inventory 守卫新增 `app/app_server_events.rs`、`app/app_server_requests.rs` 和三个 handler 符号,并禁止交互 runtime + 恢复直接 typed event 分发、禁止 client 恢复 `SessionEvent` 或 raw event 变体。inventory 更新为 + Codex/Lime 合计 658 个 Rust 文件。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server-client -p tui -p cli` + (50 + 373 + CLI library 8 / binary 40 / integration 2)、三 crate Clippy `-D warnings`、 + `npm run test:contracts`、TUI structure Vitest、`npm run smoke:cli-gate-b` 与 + `npm run smoke:tui-gate-b` 均通过。真实 CLI/TUI evidence 经过 current stdio App Server、 + canonical `turn.started,message.delta,item.started,item.completed,turn.completed`;TUI 七场景继续 + 保持 `queue-edit=ok agents-overview=ok terminal=restored`。Gate 构建仅有既有 + `app-server/src/processor/turn.rs` 两条 dead-code warning,不在本刀写集。 + +分类:`current = app/app_server_events.rs + app/app_server_requests.rs + app-server-client::AppServerEvent + typed +notification/reverse-request routing + app/agent_picker(`/subagents` 会话导航); +`deprecated = none`;`dead/deleted/forbidden-to-restore = SessionEvent、raw event 变体、旧 +lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Codex shared agents +daemon 与生产 Cloud`。下一刀从 Codex `app/input.rs` 与 Lime `App::handle_terminal_event` 做逐函数 +对照,把 App 输入 owner 迁入同名目录并继续降低 `app.rs` 体量;不得自行设计第二套 input reducer。 + +## 2026-09-06 Codex TUI input and session lifecycle owner alignment + +- 按当前 Codex `tui/src/app.rs::handle_tui_event` 与 `tui/src/app/input.rs::handle_key_event` + 拆分终端输入 owner:`runtime.rs` 直接把 `TuiEvent` 交给 `App::handle_tui_event`,后者负责 + paste 换行规范化、断线草稿、活动弹层和输入视图路由;键盘快捷键与 composer key dispatch + 进入同名 `app/input.rs`。旧 Lime-only `handle_terminal_event` 与 + `handle_disconnected_event` 生产入口已删除,没有 compat wrapper。 +- 对照 Codex `chatwidget/slash_dispatch.rs` 的真实分支修正命令语义:新增 `/agents` 并打开 + Agents Overview;`/subagents` 恢复为打开当前会话 Agent Picker。早先章节中 + “`/subagents` 打开 Overview / `agent_picker` 仅为 compat”的过渡记录由本节取代。两个入口都消费 + 本地 slash command,不进入 App Server turn input;`/agents` 描述覆盖 `zh-CN`、`zh-TW`、 + `en-US`、`ja-JP`、`ko-KR`。 +- 按 Codex `tui/src/app/session_lifecycle.rs` 把 `open_agent_picker` 迁到同名 owner;picker 继续只消费 + App Server-backed navigation/thread identity,不新增私有 daemon、历史库或第二套 runtime。 +- 按 Codex `tui/src/app.rs` 的 `mod tests;` 形状把内联 App 回归迁到同名 `app/tests.rs`,测试名称、 + 断言和覆盖范围保持不变;`app.rs` 从 1542 行降到 665 行,不再越过仓库 1000 行业务逻辑红线。 +- 结构守卫锁定 `app/input.rs`、`app/session_lifecycle.rs`、`handle_tui_event`、 + `handle_key_event`、`open_agent_picker`、`app/tests.rs` 与 `mod tests;`,并禁止 runtime 恢复两个旧 + handler。structure inventory 更新为 Codex/Lime 合计 661 个 Rust 文件。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(374/374)、`cargo test + --manifest-path "lime-rs/Cargo.toml" -p cli`(library 8 / binary 40 / integration 2)、两 crate Clippy + `-D warnings`、touched Rustfmt、TUI structure/snapshot/Gate B Vitest(11/11)、`npm run + test:contracts`、`npm run governance:legacy-report`、`npm run smoke:cli-gate-b`、`npm run + smoke:tui-gate-b` 与 `git diff --check` 通过。TUI Gate B 七场景继续保持 `queue-edit=ok + agents-overview=ok terminal=restored`;CLI Gate B 保持 JSON/JSONL/stdin/error exit/completion 全链。 + Gate 构建仍只有既有 `app-server/src/processor/turn.rs` 两条 dead-code warning,不在本节写集。 + +分类:`current = App::handle_tui_event + app/input.rs::handle_key_event + +app/session_lifecycle.rs::open_agent_picker + /agents + /subagents`;`compat/deprecated = none`; +`dead/deleted/forbidden-to-restore = handle_terminal_event、handle_disconnected_event、旧 lime-cli、 +terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产 +Cloud`。下一刀应继续对照 Codex `app/event_dispatch.rs`、`app_event.rs` 与 Lime runtime action match, +把 App action/event owner 收敛到 Codex 命名和目录,同时保持公共 JSON-RPC 主链不变。 + +## 2026-09-06 Codex reconnect owner alignment + +- 按 Codex `tui/src/app/reconnect.rs` 将 Lime 的 App Server 重连实现迁入 + `tui/src/app/reconnect.rs`,runtime 直接从该 owner 引用 `reconnect_session` 与 + `ReconnectedSession`。重连继续复用共享 `connect_session`、`thread/resume`、权限 profile + 和设置更新,不建立第二个 transport 或 runtime。 +- 顶层 `tui/src/reconnect.rs` 仅保留委托导出,未复制实现;待引用迁移完成并获得删除确认后再清理。 +- 结构守卫新增 `app/reconnect.rs` 和 runtime owner 引用断言;inventory 同步记录该 Codex 目录。 +- 分类:`current = app/reconnect.rs + App Server session resume`;`compat = reconnect.rs` + (仅委托导出);`deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、 + terminal-ui、Codex 私有 runtime/history/state DB`;`deferred = Cloud production transport`。 + +## 2026-09-06 Codex event dispatch owner alignment + +- 按 Codex `tui/src/app/event_dispatch.rs::handle_event` 建立 Lime 的 + `app/event_dispatch.rs` owner。设置、协作模式、权限 profile、模型选择和 Agents Overview + action 现在由 `App::handle_event` 消费,并通过显式 `EventContext` 复用当前 App Server session + 与共享状态引用。 +- `AppAction` 继续表示 TUI 输入 reducer 的结果,不改名为 `AppEvent`;未迁移的提交、队列、 + picker、编辑器和终端滚动 action 仍由 runtime 处理,避免把终端资源生命周期错误下沉到 App。 +- runtime 保留明确的不可达保护分支,确保未来新增 action 不会静默绕过 Codex-shaped owner; + 结构守卫新增 `event_dispatch.rs`、`handle_event`、`EventContext`、`EventDispatch` 断言。 +- 分类:`current = app/event_dispatch.rs::handle_event + EventContext`;`compat/deprecated = none`; + `dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`; + `deferred = Codex account/daemon/product-only AppEvent variants 与 Cloud production transport`。 + +## 2026-09-06 Codex thread event owner alignment + +- 按 Codex `tui/src/app/thread_events.rs` 将 Lime 的 Thread/Turn/Item 通知投影从 + `app/app_server_events.rs` 迁入 `app/thread_events.rs`。`apply_notification`、liveness + observation 和 sub-agent activity 现在由线程事件 owner 维护,App Server 文件只保留 transport + 分流、请求队列和队列刷新。 +- 保持同一 canonical `ServerNotification` 投影和 App Server request 生命周期,不引入 per-thread + 第二状态库;当前 Lime 的单投影模型仍是产品边界内的唯一 read model。 +- 分类:`current = app/thread_events.rs::{apply_notification,observe_notification,observe_item} + + ThreadEventStore + pending_interactive_replay`;`compat/deprecated = none`; + `dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`; + `deferred = Codex daemon 专属跨会话行为与 Cloud production transport`。 + +## 2026-09-06 Codex thread settings owner alignment + +- 按 Codex `tui/src/app/thread_settings.rs` 将 Lime 的模型/provider、effort、权限 profile 和 + collaboration mode 状态同步迁入 `app/thread_settings.rs`。`set_settings`、profile 去重与 + cycling、model picker catalog 更新及 mode 选择均保留原有行为和 API 名称。 +- App Server 持久化仍由 `AppServerSession` 承担;该模块只维护 TUI 的当前线程 settings/read + model,不创建 Codex legacy config 或 provider catalog 副本。 +- 分类:`current = app/thread_settings.rs`;`compat/deprecated = none`; + `dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、Codex 私有 runtime/history/state DB`; + `deferred = Codex account/managed config product-only settings`。 + +## 2026-09-06 Codex reverse request owner alignment + +- 按 Codex `tui/src/app/app_server_requests.rs` 的目录和函数命名,将 + `handle_server_request_event` 从 `app/app_server_events.rs` 迁入同名 + `app/app_server_requests.rs`。`app_server_events.rs` 现在只负责 transport event 分流和 + typed notification dispatch;reverse request 的 queue/reject、foreign thread 路由和 + current-time response 由 requests owner 维护。 +- 共享 `AppServerEvent`、App Server JSON-RPC 和 canonical Thread/Turn/Item projection 不变; + 没有新增 TUI 私有 request bus、daemon、状态库或 compat wrapper。 +- 结构守卫新增 `app/app_server_requests.rs` 文件断言,并断言 + `handle_server_request_event` 不再回流到 `app_server_events.rs`;inventory 更新为 + Codex/Lime 合计 666 个 Rust 文件。 +- 验证:`cargo check --manifest-path "lime-rs/Cargo.toml" -p tui`、TUI structure + inventory Vitest、`git diff --check`;后续完成整组 Rust/Clippy 与 Gate B 回归后再关闭本阶段。 + +分类:`current = app/app_server_events.rs + app/app_server_requests.rs + app-server-client::AppServerEvent`; +`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 lime-cli、terminal-ui、 +第二套 runtime/history/state DB`;`deferred = Codex shared agents daemon 与生产 Cloud`。 + +## 2026-09-06 Codex external-editor PTY recovery + +- 该阶段加入的 runtime 有界 EOF 重建只是定位外部编辑器恢复失败的临时实现,已由 2026-09-07 + `Tui::with_restored` 对齐替换并删除;不得恢复为第二套输入恢复策略。 +- 真实 `complete` 场景稳定复现 editor 返回后丢失提交,为后续 Codex 同构恢复入口提供了回归基线。 +- 分类:`dead/deleted/forbidden-to-restore = runtime bounded TuiEventStream restart`; + `compat/deprecated = none`;旧 terminal lifecycle、`terminal-ui` 和第二套 runtime 继续 + `dead/deleted/forbidden-to-restore`。 + +## 2026-09-07 Codex replay filter owner alignment + +- 对照 Codex `tui/src/app/replay_filter.rs`,在 Lime 当前 `tui/src/app/replay_filter.rs` 建立同名 + replay 过滤 owner,并由 `thread_events.rs` 在 snapshot drain 时调用。直接迁移 + `snapshot_has_pending_interactive_request`、`event_is_notice`、`omit_completed_agent_deltas`; + 这些函数只依赖 Lime 已有的 `ThreadEventSnapshot`、`ThreadBufferedEvent` 与 v2 typed + `ServerNotification`/`ServerRequest`。 +- Codex 的 `omit_resolved_misalignment_errors` 暂不迁移:Lime v2 当前 `CodexErrorInfo` 没有 + `MisalignmentPolicyViolation`,也没有对应的 review/continuation owner。不得为了补齐函数名而 + 伪造协议枚举、状态机或 no-op 过滤;该能力记录为 `defer`,直到 App Server current owner + 提供真实 wire 合同。 +- `thread_events.rs` 在 `drain_snapshot` 中先清理已完成 Agent message 的 streaming delta,并在 + 有最新 turn identity 时移除已被后续 turn 解决的 misalignment error;interactive request + 是否仍可 replay 继续由 `PendingInteractiveReplayState` 唯一负责。 +- 本轮不机械复制 Codex 的 `thread_routing`、`thread_session_state`、`thread_title` 或 + `transcript_export`:Lime 当前没有对应的 Codex rollout DB、HistoryCell、私有 provider/auth + 或独立 routing owner。它们分别归并现有 Thread event/session/projection owner,或标记 + `defer`,不得创建平行状态模型。 + +分类:`current = app/replay_filter.rs + thread_events.rs snapshot replay`; +`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = rollout/history DB 导出 owner、 + 旧 lime-cli、terminal-ui、第二套 runtime`;`deferred = Codex 私有 misalignment error 过滤、 + thread title generation、shared daemon 与 Cloud production transport`。 + +退出条件:replay filter 定向单测、`cargo test -p tui`、TUI Clippy、TUI structure/inventory、 +`npm run test:contracts`、`npm run governance:legacy-report` 与真实 `npm run smoke:tui-gate-b` +全部通过;未迁移的 Codex 文件在 inventory 中保持明确分类。 + +## 2026-09-07 Codex Tui external-program lifecycle alignment + +- 按 Codex `tui/src/tui.rs` 复制同名 `set_modes`、`restore_keep_raw`、 + `flush_terminal_input_buffer` 与 `Tui::with_restored` 生命周期。恢复入口依次暂停 + `EventBroker`、退出 active alternate screen、向外部编辑器交还终端、重建 TUI modes、清理编辑器 + 留下的 typeahead、重新进入 alternate screen、恢复事件并调度 frame。 +- 删除 runtime 的 50 次 `TuiEventStream` EOF 重建和分离的 `suspend/resume` 调用;终端关闭仍按 + Codex 语义由输入流 `None` 直接结束,编辑器恢复只有 `with_restored` 一个 owner。 +- 将 Lime-only `TerminalGuard`/`TuiTerminal` 改为 Codex 同名 `Tui`/`Terminal`,并更新结构测试, + 明确禁止旧类型名回流。Unix 使用 `tcflush(TCIFLUSH)`;Windows 使用 + `FlushConsoleInputBuffer`,不引入平台假实现。 +- 真实 `complete` 场景验证 editor fd 0/1/2 继承 PTY、编辑后的 prompt 进入 canonical turn 并完成; + 完整 `npm run smoke:tui-gate-b` 七场景通过,保持 + `queue-edit=ok agents-overview=ok terminal=restored`。 +- 验证:`cargo test --manifest-path "lime-rs/Cargo.toml" -p tui`(390/390)、 + `cargo clippy --manifest-path "lime-rs/Cargo.toml" -p tui --no-deps -- -D warnings`、 + `npx vitest run "scripts/app-server/tui-structure-inventory.test.mjs"`(12/12)和真实七场景 + TUI Gate B 通过;当前机器仅安装 `aarch64-apple-darwin` target,Windows 实现未在本机编译运行。 + +分类:`current = tui::{Tui,Terminal,set_modes,restore_keep_raw,flush_terminal_input_buffer} + +Tui::with_restored + EventBroker + FrameRequester`;`compat/deprecated = none`; +`dead/deleted/forbidden-to-restore = TerminalGuard、TuiTerminal、runtime bounded EOF restart、旧 +lime-cli、terminal-ui、第二套 runtime`;`deferred = Windows CI 运行证据、Codex 私有产品面与生产 +Cloud transport`。 + +## 2026-09-07 Codex crossterm fork recovery verification + +- 按 `/Users/coso/Documents/dev/rust/codex/codex-rs/Cargo.toml` 增加 workspace 级 + `[patch.crates-io]`,固定 `crossterm` 到 `openai-oss-forks/crossterm` + revision `45fecb9508105988f42fe6ff0441783ed3717f92`,并同步 `lime-rs/Cargo.lock`。 + 该 fork 的 terminal readiness、外部消费输入缓冲和不完整 escape 隔离修复直接覆盖 + TUI `Tui::with_restored` 后首个 Enter 偶发丢失的根因;没有在 Lime 增加第二套输入恢复逻辑。 +- `cargo test -p tui` 390/390、`cargo clippy -p tui --no-deps -- -D warnings`、 + `cargo check --locked -p tui` 全部通过。 +- 真实 `LIME_TUI_GATE_B_SCENARIOS=complete npm run smoke:tui-gate-b` 连续 5 次通过;每次均 + 记录 external editor 修改后的 prompt、canonical `turn.started,message.delta,item.started, + item.completed,turn.completed` 和 `terminal=restored`。完整七场景 + `npm run smoke:tui-gate-b` 同样通过,`queue-edit=ok`、`agents-overview=ok`。 + +分类保持:`current = crossterm` Codex 固定 fork + `tui::{Tui,Terminal,EventBroker,TuiEventStream}`; +`compat/deprecated = none`;`dead/deleted/forbidden-to-restore = 旧 crossterm registry 输入路径、 +TerminalGuard、TuiTerminal、runtime bounded EOF restart、旧 lime-cli、terminal-ui、第二套 runtime`; +`deferred = Windows CI 运行证据、Codex 私有产品面与生产 Cloud transport`。 diff --git a/internal/exec-plans/tui-structure-inventory.json b/internal/exec-plans/tui-structure-inventory.json index 57272da3a..899e586a0 100644 --- a/internal/exec-plans/tui-structure-inventory.json +++ b/internal/exec-plans/tui-structure-inventory.json @@ -60596,9 +60596,30 @@ }, "lime-rs/crates/tui/src": { "root": "/Users/coso/Documents/dev/ai/aiclientproxy/lime/lime-rs/crates/tui/src", - "fileCount": 64, + "fileCount": 91, "files": [ "app.rs", + "app/agent_navigation.rs", + "app/agent_picker.rs", + "app/agents_overview.rs", + "app/agents_overview_render.rs", + "app/agents_overview_tests.rs", + "app/agents_overview_threads.rs", + "app/agents_overview_view.rs", + "app/app_server_event_targets.rs", + "app/app_server_events.rs", + "app/app_server_requests.rs", + "app/event_dispatch.rs", + "app/input.rs", + "app/pending_interactive_replay.rs", + "app/reconnect.rs", + "app/replay_filter.rs", + "app/session_lifecycle.rs", + "app/tests.rs", + "app/thread_event_buffer.rs", + "app/thread_event_buffer_tests.rs", + "app/thread_events.rs", + "app/thread_settings.rs", "app_server_session.rs", "bottom_pane/approval_overlay.rs", "bottom_pane/chat_composer.rs", @@ -60617,11 +60638,14 @@ "bottom_pane/textarea/wrapping_tests.rs", "clipboard_copy.rs", "clipboard_paste.rs", + "collaboration_modes.rs", "command_popup.rs", "diff_render.rs", "entry.rs", "external_editor.rs", "highlight.rs", + "key_hint.rs", + "keymap.rs", "lib.rs", "line_truncation.rs", "locale.rs", @@ -60629,7 +60653,9 @@ "markdown/local_links.rs", "markdown/table.rs", "markdown_render.rs", + "model_catalog.rs", "model_picker.rs", + "multi_agents.rs", "pager_overlay.rs", "pending_input_preview.rs", "projection.rs", @@ -60660,16 +60686,12 @@ "tui/frame_rate_limiter.rs", "tui/frame_requester.rs", "view.rs", + "viewport.rs", "width.rs", "wrapping.rs" ], - "symbolCount": 1142, + "symbolCount": 1491, "symbols": [ - { - "path": "app.rs", - "name": "active_bottom_pane_receives_input_before_the_chat_composer", - "kind": "function" - }, { "path": "app.rs", "name": "active_turn_elapsed", @@ -60677,17 +60699,7 @@ }, { "path": "app.rs", - "name": "alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit", - "kind": "function" - }, - { - "path": "app.rs", - "name": "alt_up_requests_server_delete_before_restoring_the_last_queued_input", - "kind": "function" - }, - { - "path": "app.rs", - "name": "an_open_popup_owns_escape_before_active_turn_interruption", + "name": "adjacent_agent", "kind": "function" }, { @@ -60700,11 +60712,6 @@ "name": "AppAction", "kind": "type" }, - { - "path": "app.rs", - "name": "apply_notification", - "kind": "function" - }, { "path": "app.rs", "name": "attach_image", @@ -60712,7 +60719,12 @@ }, { "path": "app.rs", - "name": "codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft", + "name": "can_accept_direct_input", + "kind": "function" + }, + { + "path": "app.rs", + "name": "capture_current_thread_input", "kind": "function" }, { @@ -60722,37 +60734,7 @@ }, { "path": "app.rs", - "name": "copy_shortcut_and_slash_command_do_not_become_turn_input", - "kind": "function" - }, - { - "path": "app.rs", - "name": "ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state", - "kind": "function" - }, - { - "path": "app.rs", - "name": "cycle_permission_profile", - "kind": "function" - }, - { - "path": "app.rs", - "name": "escape_interrupts_only_an_active_turn_and_preserves_the_draft", - "kind": "function" - }, - { - "path": "app.rs", - "name": "failed_image_submission_can_restore_pending_attachments", - "kind": "function" - }, - { - "path": "app.rs", - "name": "handle_terminal_event", - "kind": "function" - }, - { - "path": "app.rs", - "name": "history_search_owns_escape_before_active_turn_interruption", + "name": "handle_tui_event", "kind": "function" }, { @@ -60760,11 +60742,6 @@ "name": "hydrate_thread", "kind": "function" }, - { - "path": "app.rs", - "name": "image_shortcut_attaches_and_allows_image_only_submission", - "kind": "function" - }, { "path": "app.rs", "name": "map_composer_action", @@ -60772,7 +60749,7 @@ }, { "path": "app.rs", - "name": "open_model_picker", + "name": "normalize_paste", "kind": "function" }, { @@ -60780,16 +60757,6 @@ "name": "open_status_pager", "kind": "function" }, - { - "path": "app.rs", - "name": "permission_profile_catalog_is_trimmed_deduplicated_and_used_for_cycles", - "kind": "function" - }, - { - "path": "app.rs", - "name": "queued_submission_projection_updates_by_id_and_clears_on_thread_change", - "kind": "function" - }, { "path": "app.rs", "name": "replace_composer", @@ -60805,6 +60772,11 @@ "name": "restore_queued_submission_for_edit", "kind": "function" }, + { + "path": "app.rs", + "name": "restore_thread_input", + "kind": "function" + }, { "path": "app.rs", "name": "run_local_command", @@ -60840,56 +60812,26 @@ "name": "set_locale", "kind": "function" }, - { - "path": "app.rs", - "name": "set_permission_profiles", - "kind": "function" - }, { "path": "app.rs", "name": "set_queued_submissions", "kind": "function" }, - { - "path": "app.rs", - "name": "set_settings", - "kind": "function" - }, { "path": "app.rs", "name": "set_thread_id", "kind": "function" }, - { - "path": "app.rs", - "name": "slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes", - "kind": "function" - }, - { - "path": "app.rs", - "name": "slash_popup_filters_and_executes_immediate_commands", - "kind": "function" - }, { "path": "app.rs", "name": "start_turn", "kind": "function" }, - { - "path": "app.rs", - "name": "status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed", - "kind": "function" - }, { "path": "app.rs", "name": "sync_command_popup", "kind": "function" }, - { - "path": "app.rs", - "name": "tab_queues_a_follow_up_without_submitting_the_active_turn", - "kind": "function" - }, { "path": "app.rs", "name": "take_pending_images", @@ -60897,12 +60839,1192 @@ }, { "path": "app.rs", + "name": "upsert_queued_submission", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "active_agent_label", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "active_agent_label_tracks_current_thread", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "adjacent_thread_id", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "adjacent_thread_id_wraps_in_spawn_order", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "AgentNavigationDirection", + "kind": "type" + }, + { + "path": "app/agent_navigation.rs", + "name": "AgentNavigationState", + "kind": "type" + }, + { + "path": "app/agent_navigation.rs", + "name": "clear", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "get", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "is_empty", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "is_parent_owned", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "key_label", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "mark_closed", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "mark_parent_owned", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "mark_running", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "mark_stopped", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "ordered_path_backed_subagent_threads", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "ordered_threads", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "picker_subtitle", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "picker_subtitle_mentions_codex_shortcuts", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "populated_state", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "record_sub_agent_activity", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "remove", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "set_agent_path", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "set_running", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "stopped_activity_does_not_revive_after_liveness_terminal_state", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "tracked_thread_ids", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "upsert", + "kind": "function" + }, + { + "path": "app/agent_navigation.rs", + "name": "upsert_preserves_first_seen_order", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "AgentPicker", + "kind": "type" + }, + { + "path": "app/agent_picker.rs", + "name": "AgentPickerAction", + "kind": "type" + }, + { + "path": "app/agent_picker.rs", + "name": "AgentPickerEntry", + "kind": "type" + }, + { + "path": "app/agent_picker.rs", + "name": "from_navigation", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "handle_event", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "is_empty", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "picker", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "picker_copy_and_status_render_for_every_product_locale", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "picker_escape_cancels_and_narrow_render_stays_bounded", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "picker_selects_rows_in_stable_navigation_order", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "render", + "kind": "function" + }, + { + "path": "app/agent_picker.rs", + "name": "rows", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "agents_overview_group", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "agents_overview_view", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "AgentsOverviewState", + "kind": "type" + }, + { + "path": "app/agents_overview.rs", + "name": "apply_agents_overview_thread_refresh", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "apply_refresh", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "begin_refresh", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "build_rows", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "dispatch_agents_overview_task", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "drop", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "new", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "open_agents_overview", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "repaint_agents_overview", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "replace_threads", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "select_agents_overview_thread", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "stop_agents_overview_thread", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "submit_agents_overview_prompt", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "sync_view_state", + "kind": "function" + }, + { + "path": "app/agents_overview.rs", + "name": "take_refresh_pending", + "kind": "function" + }, + { + "path": "app/agents_overview_render.rs", + "name": "overview_render_is_bounded_on_wide_and_narrow_terminals", + "kind": "function" + }, + { + "path": "app/agents_overview_render.rs", + "name": "overview_render_shows_localized_new_task_input_without_overflow", + "kind": "function" + }, + { + "path": "app/agents_overview_render.rs", + "name": "render", + "kind": "function" + }, + { + "path": "app/agents_overview_render.rs", + "name": "render_details", + "kind": "function" + }, + { + "path": "app/agents_overview_render.rs", + "name": "row", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "agents_overview_buffers_latest_notification_during_refresh", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "agents_overview_group_bubbles_child_status_and_hides_ephemeral_threads", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "agents_overview_refresh_coalesces_requests_and_exposes_codex_state_fields", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "agents_overview_refresh_ignores_stale_generation", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "agents_overview_refresh_retains_locally_observed_threads", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "background_thread_notifications_do_not_mutate_the_current_projection", + "kind": "function" + }, + { + "path": "app/agents_overview_tests.rs", + "name": "thread", + "kind": "function" + }, + { + "path": "app/agents_overview_threads.rs", + "name": "refresh_agents_overview_threads", + "kind": "function" + }, + { + "path": "app/agents_overview_threads.rs", + "name": "refresh_changed_agents_overview_threads", + "kind": "function" + }, + { + "path": "app/agents_overview_threads.rs", + "name": "start_agents_overview_refresh", + "kind": "function" + }, + { + "path": "app/agents_overview_threads.rs", + "name": "track_agents_overview_notification", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "AgentsOverviewAction", + "kind": "type" + }, + { + "path": "app/agents_overview_view.rs", + "name": "AgentsOverviewGroup", + "kind": "type" + }, + { + "path": "app/agents_overview_view.rs", + "name": "AgentsOverviewInputMode", + "kind": "type" + }, + { + "path": "app/agents_overview_view.rs", + "name": "AgentsOverviewRow", + "kind": "type" + }, + { + "path": "app/agents_overview_view.rs", + "name": "AgentsOverviewView", + "kind": "type" + }, + { + "path": "app/agents_overview_view.rs", + "name": "for_status", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "handle_event", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "input", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "input_mode", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "is_searching", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "label", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "move_selection", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "new", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "overview_codex_shortcuts_dispatch_rename_stop_and_resume", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "overview_escape_and_refresh_are_explicit_actions", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "overview_groups_and_selects_current_thread", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "overview_rename_accepts_shifted_characters", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "overview_search_filters_and_enter_selects", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "row", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "search", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "selected_index", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "selected_row", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "selected_thread_id", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "status_grouping", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "thread", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "update_rows", + "kind": "function" + }, + { + "path": "app/agents_overview_view.rs", + "name": "visible_rows", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "mcp_startup_notifications_without_threads_are_app_scoped", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "server_notification_thread_target", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "server_request_thread_id", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "server_requests_expose_their_thread_target", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "ServerNotificationThreadTarget", + "kind": "type" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "thread_notifications_route_to_their_thread", + "kind": "function" + }, + { + "path": "app/app_server_event_targets.rs", + "name": "warning_notifications_without_threads_are_global", + "kind": "function" + }, + { + "path": "app/app_server_events.rs", + "name": "handle_app_server_event", + "kind": "function" + }, + { + "path": "app/app_server_events.rs", + "name": "handle_server_notification_event", + "kind": "function" + }, + { + "path": "app/app_server_events.rs", + "name": "refresh_queued_submissions", + "kind": "function" + }, + { + "path": "app/app_server_requests.rs", + "name": "handle_server_request_event", + "kind": "function" + }, + { + "path": "app/event_dispatch.rs", + "name": "EventContext", + "kind": "type" + }, + { + "path": "app/event_dispatch.rs", + "name": "EventDispatch", + "kind": "type" + }, + { + "path": "app/event_dispatch.rs", + "name": "handle_event", + "kind": "function" + }, + { + "path": "app/input.rs", + "name": "handle_key_event", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_exec_approval_item", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_exec_approval_turn", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_patch_approval_item", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_patch_approval_turn", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_request_permissions_turn", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "clear_request_user_input_turn", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "closing_thread_clears_all_prompt_categories", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "each_interactive_request_is_pending_until_resolved", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "elicitation", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "ElicitationRequestKey", + "kind": "type" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "evicted_request_does_not_replay", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "exec_approval", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "has_pending_thread_approvals", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "has_pending_thread_user_input", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "item_started_clears_command_and_file_approval_by_item_id", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "new", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "note_evicted_server_request", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "note_outbound_response", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "note_server_notification", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "note_server_request", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "outbound_response_clears_only_the_matching_request", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "patch_approval", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "PendingInteractiveReplayState", + "kind": "type" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "PendingInteractiveRequest", + "kind": "type" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "permissions_approval", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "remove_call_id_from_turn_map", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "remove_call_id_from_turn_map_entry", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "remove_request", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "request_user_input", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "resolved", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "server_resolution_clears_request_identity_and_category", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "should_replay_snapshot_request", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "turn_completed", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "turn_completion_clears_all_turn_indexed_prompts", + "kind": "function" + }, + { + "path": "app/pending_interactive_replay.rs", + "name": "user_input_requests_are_removed_in_fifo_order_per_turn", + "kind": "function" + }, + { + "path": "app/reconnect.rs", + "name": "reconnect_session", + "kind": "function" + }, + { + "path": "app/reconnect.rs", + "name": "ReconnectedSession", + "kind": "type" + }, + { + "path": "app/replay_filter.rs", + "name": "completed_agent", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "delta", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "event_is_notice", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "event_is_notice_matches_warning_notifications", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "omit_completed_agent_deltas", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "omit_completed_agent_deltas_removes_only_replaced_streams", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "snapshot_has_pending_interactive_request", + "kind": "function" + }, + { + "path": "app/replay_filter.rs", + "name": "snapshot_has_pending_interactive_request_matches_user_input", + "kind": "function" + }, + { + "path": "app/session_lifecycle.rs", + "name": "open_agent_picker", + "kind": "function" + }, + { + "path": "app/session_lifecycle.rs", + "name": "resume_target_session", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "active_bottom_pane_receives_input_before_the_chat_composer", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "agents_slash_command_opens_the_agents_overview", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "alt_right_switches_to_the_next_agent_in_spawn_order", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "alt_up_requests_server_delete_before_restoring_the_last_queued_input", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "an_open_popup_owns_escape_before_active_turn_interruption", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "backtab_cycles_server_collaboration_modes_only_when_idle", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "copy_shortcut_and_slash_command_do_not_become_turn_input", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "disconnected_input_edits_locally_without_submit_or_queue", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "disconnected_paste_and_ctrl_c_are_handled_at_the_app_boundary", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "dispatch_connected_input", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "dispatch_disconnected_input", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "escape_interrupts_only_an_active_turn_and_preserves_the_draft", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "failed_image_submission_can_restore_pending_attachments", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "history_search_owns_escape_before_active_turn_interruption", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "image_shortcut_attaches_and_allows_image_only_submission", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "permission_profile_catalog_is_trimmed_deduplicated_and_used_for_cycles", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "queued_submission_projection_updates_by_id_and_clears_on_thread_change", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "resume_picker_owns_input_until_cancelled", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "resume_slash_command_opens_the_shared_picker_action", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "settings_updates_keep_the_active_collaboration_mode_in_sync", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "slash_popup_filters_and_executes_immediate_commands", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "sub_agent_activity_updates_navigation_liveness_and_label", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "subagent_activity_marks_the_thread_parent_owned_before_resume", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "subagents_slash_command_opens_the_codex_named_picker", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "tab_queues_a_follow_up_without_submitting_the_active_turn", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "to_tui_event", + "kind": "function" + }, + { + "path": "app/tests.rs", "name": "turn_completion_clears_the_active_status_timer", "kind": "function" }, { - "path": "app.rs", - "name": "upsert_queued_submission", + "path": "app/thread_event_buffer.rs", + "name": "evict_overflowing_events", + "kind": "function" + }, + { + "path": "app/thread_event_buffer.rs", + "name": "push_buffered_event", + "kind": "function" + }, + { + "path": "app/thread_event_buffer.rs", + "name": "push_replay_notification", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "delta", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_coalesces_only_adjacent_matching_agent_message_deltas", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_does_not_replay_server_resolved_request", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_drops_requests_when_turn_completes_or_thread_closes", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_preserves_requests_while_evicting_old_notifications", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_request_capacity_fails_closed_without_evicting_requests", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "thread_event_store_tracks_delta_bytes_after_coalescing", + "kind": "function" + }, + { + "path": "app/thread_event_buffer_tests.rs", + "name": "user_input_request", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "apply_notification", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "drain_snapshot", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "enqueue_thread_request", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "ensure_thread_channel", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "handle_thread_event_replay", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "new", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "new", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "note_outbound_response", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "note_outbound_response", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "observe_item", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "observe_notification", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "push_notification", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "push_notification_ref", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "push_request", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "rebase_buffer_after_session_refresh", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "replay_thread_snapshot", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "snapshot", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "take_thread_event_snapshot", + "kind": "function" + }, + { + "path": "app/thread_events.rs", + "name": "ThreadBufferedEvent", + "kind": "type" + }, + { + "path": "app/thread_events.rs", + "name": "ThreadEventChannel", + "kind": "type" + }, + { + "path": "app/thread_events.rs", + "name": "ThreadEventSnapshot", + "kind": "type" + }, + { + "path": "app/thread_events.rs", + "name": "ThreadEventStore", + "kind": "type" + }, + { + "path": "app/thread_settings.rs", + "name": "cycle_permission_profile", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "next_collaboration_mode", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "open_model_picker", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "plan_mode", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "set_collaboration_modes", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "set_permission_profiles", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "set_settings", + "kind": "function" + }, + { + "path": "app/thread_settings.rs", + "name": "sync_default_collaboration_mode", "kind": "function" }, { @@ -60925,11 +62047,6 @@ "name": "AppServerSession", "kind": "type" }, - { - "path": "app_server_session.rs", - "name": "archive_thread", - "kind": "function" - }, { "path": "app_server_session.rs", "name": "client_message_id", @@ -60965,6 +62082,11 @@ "name": "interrupt", "kind": "function" }, + { + "path": "app_server_session.rs", + "name": "list_collaboration_modes", + "kind": "function" + }, { "path": "app_server_session.rs", "name": "list_models", @@ -60980,11 +62102,6 @@ "name": "list_queued_submissions", "kind": "function" }, - { - "path": "app_server_session.rs", - "name": "list_thread_page", - "kind": "function" - }, { "path": "app_server_session.rs", "name": "next_event", @@ -61005,16 +62122,6 @@ "name": "read_prompt_history", "kind": "function" }, - { - "path": "app_server_session.rs", - "name": "read_thread", - "kind": "function" - }, - { - "path": "app_server_session.rs", - "name": "reject_raw_server_request", - "kind": "function" - }, { "path": "app_server_session.rs", "name": "reject_server_request", @@ -61055,6 +62162,11 @@ "name": "start_thread", "kind": "function" }, + { + "path": "app_server_session.rs", + "name": "start_thread_with_session_start_source", + "kind": "function" + }, { "path": "app_server_session.rs", "name": "start_turn", @@ -61070,11 +62182,36 @@ "name": "steer_turn_input", "kind": "function" }, + { + "path": "app_server_session.rs", + "name": "thread_archive", + "kind": "function" + }, { "path": "app_server_session.rs", "name": "thread_id", "kind": "function" }, + { + "path": "app_server_session.rs", + "name": "thread_list", + "kind": "function" + }, + { + "path": "app_server_session.rs", + "name": "thread_read", + "kind": "function" + }, + { + "path": "app_server_session.rs", + "name": "thread_set_name", + "kind": "function" + }, + { + "path": "app_server_session.rs", + "name": "thread_unarchive", + "kind": "function" + }, { "path": "app_server_session.rs", "name": "tui_client_uses_stable_identity_and_v2_capabilities", @@ -61082,7 +62219,17 @@ }, { "path": "app_server_session.rs", - "name": "unarchive_thread", + "name": "turn_interrupt", + "kind": "function" + }, + { + "path": "app_server_session.rs", + "name": "turn_start", + "kind": "function" + }, + { + "path": "app_server_session.rs", + "name": "update_collaboration_mode", "kind": "function" }, { @@ -61240,6 +62387,11 @@ "name": "desired_height", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "editor_word_and_kill_shortcuts_share_textarea_semantics", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "handle_history_search_key", @@ -61305,6 +62457,11 @@ "name": "key", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "key_release_does_not_insert_or_submit", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "load_history", @@ -61345,16 +62502,6 @@ "name": "remove_last_pending_image", "kind": "function" }, - { - "path": "bottom_pane/chat_composer.rs", - "name": "remove_next_grapheme", - "kind": "function" - }, - { - "path": "bottom_pane/chat_composer.rs", - "name": "remove_previous_grapheme", - "kind": "function" - }, { "path": "bottom_pane/chat_composer.rs", "name": "replace", @@ -61525,11 +62672,21 @@ "name": "disconnected_edit_preserves_draft_and_cancels_history_preview", "kind": "function" }, + { + "path": "bottom_pane/chat_composer/reconnect_tests.rs", + "name": "disconnected_editing_preserves_graphemes_and_cursor_boundaries", + "kind": "function" + }, { "path": "bottom_pane/chat_composer/reconnect_tests.rs", "name": "disconnected_enter_and_tab_leave_input_available_for_reconnect", "kind": "function" }, + { + "path": "bottom_pane/chat_composer/reconnect_tests.rs", + "name": "disconnected_repeat_key_is_editable_but_never_submits", + "kind": "function" + }, { "path": "bottom_pane/chat_composer/reconnect_tests.rs", "name": "key", @@ -61610,6 +62767,11 @@ "name": "queues_requests_and_resolves_them_in_arrival_order", "kind": "function" }, + { + "path": "bottom_pane/mod.rs", + "name": "unsupported_requests_return_for_rejection", + "kind": "function" + }, { "path": "bottom_pane/render.rs", "name": "desired_height_with_locale", @@ -61765,6 +62927,11 @@ "name": "halfwidth_sound_marks_are_truncated_at_a_grapheme_boundary", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "beginning_of_previous_word", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "codex_buffer_replacement_preserves_cursor_only_at_valid_boundaries", @@ -61775,6 +62942,21 @@ "name": "codex_cursor_pos_api_keeps_cursor_visible_in_a_scrolled_viewport", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "codex_input_dispatches_emacs_style_editor_bindings", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "codex_word_boundaries_handle_cjk_and_separator_runs", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "codex_word_delete_and_line_kill_preserve_unicode_boundaries", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "cursor", @@ -61795,6 +62977,31 @@ "name": "cursor_position", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "delete_backward", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "delete_backward_word", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "delete_forward", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "delete_forward_kill", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "delete_forward_word", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "desired_height", @@ -61805,6 +63012,16 @@ "name": "effective_scroll", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "end_of_next_word", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "input", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "insert", @@ -61825,6 +63042,26 @@ "name": "is_empty", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "is_word_separator", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "kill_range", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "kill_to_beginning_of_line", + "kind": "function" + }, + { + "path": "bottom_pane/textarea.rs", + "name": "kill_to_end_of_line", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "line_end", @@ -61875,6 +63112,11 @@ "name": "next_grapheme_end", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "next_grapheme_end_at", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "previous_grapheme_start", @@ -61925,11 +63167,21 @@ "name": "set_text_clearing_elements", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "split_word_pieces", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "State", "kind": "type" }, + { + "path": "bottom_pane/textarea.rs", + "name": "stateful_render_clears_rows_outside_the_current_draft", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "take", @@ -61965,6 +63217,11 @@ "name": "wrapped_lines", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "yank", + "kind": "function" + }, { "path": "bottom_pane/textarea/wrapping.rs", "name": "cursor_position", @@ -62265,6 +63522,66 @@ "name": "write_temp_png", "kind": "function" }, + { + "path": "collaboration_modes.rs", + "name": "default_mask", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "default_mode_mask", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "empty_catalog_has_no_mode", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "filtered_presets", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "mask", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "mask_for_kind", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "mode_selection_prefers_default_and_cycles_in_server_order", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "next_mask", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "plan_mask", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "to_mode", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "to_mode_preserves_an_explicit_effort_clear", + "kind": "function" + }, + { + "path": "collaboration_modes.rs", + "name": "to_mode_uses_mask_values_and_fallbacks", + "kind": "function" + }, { "path": "command_popup.rs", "name": "CommandPopup", @@ -62560,6 +63877,11 @@ "name": "lines", "kind": "function" }, + { + "path": "entry.rs", + "name": "multi_agent_titles_are_localized_at_the_render_boundary", + "kind": "function" + }, { "path": "entry.rs", "name": "plan_statuses_use_stable_checkbox_styles", @@ -62755,6 +64077,66 @@ "name": "unknown_language_falls_back_to_plain_text", "kind": "function" }, + { + "path": "key_hint.rs", + "name": "control_and_alt_characters_are_not_plain_text", + "kind": "function" + }, + { + "path": "key_hint.rs", + "name": "is_plain_text_key_event", + "kind": "function" + }, + { + "path": "key_hint.rs", + "name": "shifted_characters_are_plain_text", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "AgentsKeymap", + "kind": "type" + }, + { + "path": "keymap.rs", + "name": "codex_agents_defaults_are_stable", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "control", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "new_task", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "rename", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "resume", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "search", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "stop", + "kind": "function" + }, + { + "path": "keymap.rs", + "name": "toggle_grouping", + "kind": "function" + }, { "path": "line_truncation.rs", "name": "ellipsis_inherits_the_last_visible_span_style", @@ -62785,6 +64167,56 @@ "name": "add_notes", "kind": "function" }, + { + "path": "locale.rs", + "name": "agent_picker_empty", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agent_picker_footer", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agent_picker_title", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_controls_cover_all_product_locales", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_footer", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_group_label", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_input_prefix", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_search_prefix", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_task_statuses_cover_all_product_locales", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "agents_overview_title", + "kind": "function" + }, { "path": "locale.rs", "name": "approval_option", @@ -62795,6 +64227,11 @@ "name": "approval_title", "kind": "function" }, + { + "path": "locale.rs", + "name": "collaboration_mode_statuses_cover_all_product_locales", + "kind": "function" + }, { "path": "locale.rs", "name": "command_output_omission_markers_cover_all_product_locales", @@ -62860,6 +64297,16 @@ "name": "model_label", "kind": "function" }, + { + "path": "locale.rs", + "name": "multi_agent", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "multi_agent_titles_cover_all_product_locales", + "kind": "function" + }, { "path": "locale.rs", "name": "no_questions", @@ -63510,6 +64957,36 @@ "name": "render_markdown_text_keeps_plain_content", "kind": "function" }, + { + "path": "model_catalog.rs", + "name": "model", + "kind": "function" + }, + { + "path": "model_catalog.rs", + "name": "model_catalog_preserves_server_order", + "kind": "function" + }, + { + "path": "model_catalog.rs", + "name": "ModelCatalog", + "kind": "type" + }, + { + "path": "model_catalog.rs", + "name": "new", + "kind": "function" + }, + { + "path": "model_catalog.rs", + "name": "try_list_models", + "kind": "function" + }, + { + "path": "model_catalog.rs", + "name": "with_collaboration_modes", + "kind": "function" + }, { "path": "model_picker.rs", "name": "handle_event", @@ -63580,6 +65057,136 @@ "name": "visible_models", "kind": "function" }, + { + "path": "multi_agents.rs", + "name": "activity", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "AgentPickerThreadEntry", + "kind": "type" + }, + { + "path": "multi_agents.rs", + "name": "collab_agent_status_label", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "collab_summary", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "collab_summary_for_item", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "compact_text", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "format_agent_picker_item_name", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "interacted_sub_agent_activity_does_not_change_liveness", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "interrupted_sub_agent_activity_stops_running_liveness", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "MultiAgentHistoryCell", + "kind": "type" + }, + { + "path": "multi_agents.rs", + "name": "next_agent_shortcut", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "next_agent_shortcut_matches", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "non_empty", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "previous_agent_shortcut", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "previous_agent_shortcut_matches", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "prompt_line", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "spawn_request_summary", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "spawn_request_summary_preserves_model_and_effort", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "SpawnRequestSummary", + "kind": "type" + }, + { + "path": "multi_agents.rs", + "name": "status_summary_text", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "sub_agent_activity_display", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "sub_agent_activity_summary", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "SubAgentActivityDisplay", + "kind": "type" + }, + { + "path": "multi_agents.rs", + "name": "tool_call_history_cell", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "tool_call_history_cells_cover_collaboration_lifecycle", + "kind": "function" + }, + { + "path": "multi_agents.rs", + "name": "wait_complete_lines", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "buffer_text", @@ -63740,21 +65347,11 @@ "name": "apply", "kind": "function" }, - { - "path": "projection.rs", - "name": "collab_agent_status_label", - "kind": "function" - }, { "path": "projection.rs", "name": "collab_entry_status", "kind": "function" }, - { - "path": "projection.rs", - "name": "collab_summary", - "kind": "function" - }, { "path": "projection.rs", "name": "command_entry_status", @@ -63890,11 +65487,6 @@ "name": "status", "kind": "function" }, - { - "path": "projection.rs", - "name": "sub_agent_status", - "kind": "function" - }, { "path": "projection.rs", "name": "TranscriptEntry", @@ -63915,6 +65507,16 @@ "name": "collab_agent_summary_keeps_requested_model_effort_and_prompt", "kind": "function" }, + { + "path": "projection_tests.rs", + "name": "collab_resume_projection_keeps_resume_result", + "kind": "function" + }, + { + "path": "projection_tests.rs", + "name": "collab_wait_projection_keeps_per_agent_results", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "command_item", @@ -63980,11 +65582,6 @@ "name": "turn_terminal_status_settles_in_progress_items", "kind": "function" }, - { - "path": "reconnect.rs", - "name": "reconnect_session", - "kind": "function" - }, { "path": "resume_picker.rs", "name": "action_label", @@ -64045,6 +65642,16 @@ "name": "handle_event", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "handle_transcript_pager_event", + "kind": "function" + }, + { + "path": "resume_picker.rs", + "name": "has_more_pages", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "invalidate_thread_list", @@ -64330,6 +65937,11 @@ "name": "toggle", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "toggle_density", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "toggle_filter", @@ -64360,6 +65972,11 @@ "name": "transcript_load_event_records_loaded_and_failed_states", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "transcript_pager_is_open", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "transcript_pager_renders_canonical_entries_scrolls_and_closes_with_ctrl_t", @@ -64630,11 +66247,6 @@ "name": "real_stdio_unavailable_backend_fails_closed_without_provider", "kind": "function" }, - { - "path": "runtime.rs", - "name": "refresh_queued_submissions", - "kind": "function" - }, { "path": "runtime.rs", "name": "run_exec", @@ -64710,6 +66322,11 @@ "name": "required_test_path", "kind": "function" }, + { + "path": "runtime_pty_tests.rs", + "name": "terminal_screen_text", + "kind": "function" + }, { "path": "runtime_pty_tests.rs", "name": "visible_terminal_text", @@ -64735,6 +66352,11 @@ "name": "wait_for_marker_after", "kind": "function" }, + { + "path": "runtime_pty_tests.rs", + "name": "wait_for_screen_marker", + "kind": "function" + }, { "path": "selection_list.rs", "name": "selected_rows_use_codex_marker_and_bold_cyan_style", @@ -65550,11 +67172,31 @@ "name": "enter", "kind": "function" }, + { + "path": "tui.rs", + "name": "enter_alternate_screen", + "kind": "function" + }, { "path": "tui.rs", "name": "event_stream", "kind": "function" }, + { + "path": "tui.rs", + "name": "flush_terminal_input_buffer", + "kind": "function" + }, + { + "path": "tui.rs", + "name": "flush_terminal_input_buffer", + "kind": "function" + }, + { + "path": "tui.rs", + "name": "flush_terminal_input_buffer", + "kind": "function" + }, { "path": "tui.rs", "name": "frame_requester", @@ -65570,6 +67212,11 @@ "name": "is_terminal_focused", "kind": "function" }, + { + "path": "tui.rs", + "name": "leave_alternate_screen", + "kind": "function" + }, { "path": "tui.rs", "name": "pause_events", @@ -65582,12 +67229,12 @@ }, { "path": "tui.rs", - "name": "restore_terminal_state", + "name": "restore_keep_raw", "kind": "function" }, { "path": "tui.rs", - "name": "resume", + "name": "restore_terminal_state", "kind": "function" }, { @@ -65597,9 +67244,19 @@ }, { "path": "tui.rs", - "name": "suspend", + "name": "set_modes", "kind": "function" }, + { + "path": "tui.rs", + "name": "sync_viewport", + "kind": "function" + }, + { + "path": "tui.rs", + "name": "Terminal", + "kind": "type" + }, { "path": "tui.rs", "name": "terminal_mut", @@ -65607,7 +67264,7 @@ }, { "path": "tui.rs", - "name": "TerminalGuard", + "name": "Tui", "kind": "type" }, { @@ -65617,8 +67274,18 @@ }, { "path": "tui.rs", - "name": "TuiTerminal", - "kind": "type" + "name": "update_viewport", + "kind": "function" + }, + { + "path": "tui.rs", + "name": "viewport_area", + "kind": "function" + }, + { + "path": "tui.rs", + "name": "with_restored", + "kind": "function" }, { "path": "tui/event_stream.rs", @@ -65960,6 +67627,16 @@ "name": "buffer_text", "kind": "function" }, + { + "path": "view.rs", + "name": "dispatch_connected_input", + "kind": "function" + }, + { + "path": "view.rs", + "name": "footer_renders_the_active_agent_label", + "kind": "function" + }, { "path": "view.rs", "name": "history_search_footer_shows_localized_query_without_hiding_composer", @@ -66100,6 +67777,101 @@ "name": "wrapped_transcript_scroll_uses_visual_rows", "kind": "function" }, + { + "path": "viewport.rs", + "name": "alternate_screen_round_trip_restores_inline_viewport", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "clear_for_viewport_change", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "cursor_anchor", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "enter_alternate_screen", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "first_viewport_change_clears_from_new_area_when_old_area_is_empty", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "inline_viewport_shrinks_into_terminal_bounds", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "inline_viewport_stays_bottom_aligned_when_terminal_grows", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "invalidate_viewport", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "invalidate_viewport_sets_repaint_marker", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "leave_alternate_screen", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "leaving_alternate_without_saved_area_is_a_noop", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "new", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "repeated_alternate_entry_preserves_original_inline_area", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "resize", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "resize_keeps_non_bottom_aligned_viewport_position", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "set_viewport_area", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "update_cursor_anchor", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "update_inline", + "kind": "function" + }, + { + "path": "viewport.rs", + "name": "ViewportState", + "kind": "type" + }, { "path": "width.rs", "name": "display_width", @@ -66376,40 +68148,27 @@ "kind": "function" } ], - "treeSha256": "32a9f69b73411b4248ad09bd2f30db83ad0077232f0d67c394f2c7fe33725f79" + "treeSha256": "5f8c14d0e139f56d46ac5f0995372e21fd3516105d2b37ddb7571c5367f58369" } }, "comparisons": { "filesMissingInLime": [ "additional_dirs.rs", "app/agent_message_consolidation.rs", - "app/agent_navigation.rs", - "app/agent_picker.rs", "app/agent_status_feed.rs", "app/agent_status_feed_tests.rs", - "app/agents_overview.rs", - "app/agents_overview_render.rs", - "app/agents_overview_tests.rs", - "app/agents_overview_threads.rs", - "app/agents_overview_view.rs", - "app/app_server_event_targets.rs", - "app/app_server_events.rs", - "app/app_server_requests.rs", "app/backend_banner_fallback.rs", "app/background_requests.rs", "app/config_persistence.rs", "app/connector_mentions.rs", "app/connector_mentions_tests.rs", - "app/event_dispatch.rs", "app/exit_summary.rs", "app/file_change_approvals.rs", "app/history_pagination.rs", "app/history_ui.rs", "app/history_ui_tests.rs", - "app/input.rs", "app/loaded_threads.rs", "app/misalignment_policy.rs", - "app/pending_interactive_replay.rs", "app/permission_shortcuts.rs", "app/pets.rs", "app/platform_actions.rs", @@ -66417,20 +68176,16 @@ "app/rate_limit_refresh.rs", "app/recap.rs", "app/recap_tests.rs", - "app/reconnect.rs", - "app/replay_filter.rs", "app/resize_reflow.rs", "app/resize_reflow_tests.rs", "app/resume_config.rs", "app/safety_buffering.rs", "app/safety_buffering_tests.rs", - "app/session_lifecycle.rs", "app/session_picker.rs", "app/side.rs", "app/startup.rs", "app/startup_prompts.rs", "app/test_support.rs", - "app/tests.rs", "app/tests/active_reconnect_tests.rs", "app/tests/advanced_reasoning_tests.rs", "app/tests/agents_navigation_tests.rs", @@ -66460,13 +68215,9 @@ "app/tests/thread_usage.rs", "app/tests/transcript_composer.rs", "app/tests/turn_submission.rs", - "app/thread_event_buffer.rs", - "app/thread_event_buffer_tests.rs", - "app/thread_events.rs", "app/thread_goal_actions.rs", "app/thread_routing.rs", "app/thread_session_state.rs", - "app/thread_settings.rs", "app/thread_title.rs", "app/thread_title_tests.rs", "app/transcript_export.rs", @@ -66683,7 +68434,6 @@ "chatwidget/windows_sandbox_prompts.rs", "chatwidget/working_directory.rs", "cli.rs", - "collaboration_modes.rs", "color.rs", "config_update.rs", "config_update_tests.rs", @@ -66749,8 +68499,6 @@ "inline_visualization/viewer.rs", "inline_visualization_tests.rs", "insert_history.rs", - "key_hint.rs", - "keymap.rs", "keymap/bindings.rs", "keymap/chords.rs", "keymap/chords_tests.rs", @@ -66780,10 +68528,8 @@ "markdown_stream.rs", "markdown_text_merge.rs", "mention_codec.rs", - "model_catalog.rs", "model_migration.rs", "motion.rs", - "multi_agents.rs", "named_session_lookup.rs", "named_session_lookup_tests.rs", "notifications/bel.rs", @@ -66931,7 +68677,8 @@ "runtime_pty_tests.rs", "settings.rs", "status_indicator.rs", - "view.rs" + "view.rs", + "viewport.rs" ], "symbolNamesMissingInLime": [ "ActionMenuOption", @@ -66950,17 +68697,9 @@ "AgentMarkdownCell", "AgentMessageCell", "AgentMetadata", - "AgentNavigationDirection", - "AgentNavigationState", - "AgentPickerThreadEntry", "AgentStatusHistoryCell", "AgentStatusThreadPreview", - "AgentsKeymap", - "AgentsOverviewGroup", - "AgentsOverviewRow", - "AgentsOverviewState", "AgentsOverviewThreadRefresh", - "AgentsOverviewView", "AgentsOverviewViewState", "AltScreenGuard", "AmbientPet", @@ -67129,7 +68868,6 @@ "EffortIgnition", "EffortStatusLineTransition", "EffortTier", - "ElicitationRequestKey", "EnableAlternateScroll", "EnableModifyOtherKeys", "EncodedImageFormat", @@ -67351,7 +69089,6 @@ "MisalignmentViolation", "MixedUrlWord", "MockResponses", - "ModelCatalog", "ModelMigrationCopy", "ModelMigrationOutcome", "ModelMigrationScreen", @@ -67404,8 +69141,6 @@ "PendingHistoryLines", "PendingHistorySearch", "PendingInputPreview", - "PendingInteractiveReplayState", - "PendingInteractiveRequest", "PendingPasteHandling", "PendingRecovery", "PendingSteer", @@ -67578,7 +69313,6 @@ "SelectionToggleAction", "SelectionViewParams", "SendArguments", - "ServerNotificationThreadTarget", "ServiceTierCommand", "SessionArchiveAction", "SessionArchiveCommandOptions", @@ -67622,7 +69356,6 @@ "SlashCommandItem", "SlashInput", "SlashValidation", - "SpawnRequestSummary", "SpendControlLimitSnapshotDisplay", "StablePrefixLenCache", "StandaloneSelectionView", @@ -67675,7 +69408,6 @@ "StreamingPlanTailCell", "StreamingRender", "StyledMarkerRenderable", - "SubAgentActivityDisplay", "SubmissionValidation", "SummaryHintKind", "SummaryLeft", @@ -67690,7 +69422,6 @@ "TargetOs", "TaskMention", "TemporaryStructuredThreadOptions", - "Terminal", "TerminalInitializationGuard", "TerminalRestoreGuard", "TerminalStderrGuard", @@ -67706,12 +69437,8 @@ "ThemePreviewNarrowRenderable", "ThemePreviewWideRenderable", "ThreadAttachPresentation", - "ThreadBufferedEvent", "ThreadComposerState", "ThreadEventAttachment", - "ThreadEventChannel", - "ThreadEventSnapshot", - "ThreadEventStore", "ThreadGoalSetMode", "ThreadHistoryPagination", "ThreadHistorySupport", @@ -67758,7 +69485,6 @@ "TrustDirectoryWidget", "TrustedFileLink", "Tty", - "Tui", "TuiSessionReporter", "TuiSharedCliOptions", "TurnAbortReason", @@ -67886,8 +69612,6 @@ "activate_thread_channel", "activate_thread_for_replay", "active", - "active_agent_label", - "active_agent_label_tracks_current_thread", "active_binding_specs", "active_blob", "active_cell_is_stream_tail", @@ -68013,9 +69737,7 @@ "adjacent_file_completion_inserts_leading_separator", "adjacent_image_completion_inserts_leading_separator", "adjacent_plugin_completion_inserts_separator_snapshot", - "adjacent_thread_id", "adjacent_thread_id_with_backfill", - "adjacent_thread_id_wraps_in_spawn_order", "adjust_pos_out_of_elements", "adjust_start_for_wrapped_selection_visibility", "advance_older_search_after_entry_miss", @@ -68060,11 +69782,9 @@ "agents_navigation_hint_snapshots", "agents_navigation_requires_local_daemon", "agents_overview_can_be_remapped_and_rejects_conflicts", - "agents_overview_group", "agents_overview_seeds_loaded_threads_when_recent_listing_is_unavailable", "agents_overview_stop_uses_full_history_after_legacy_negotiation", "agents_overview_stop_uses_history_mode_for_turn_lookup", - "agents_overview_view", "agents_resume_default_yields_to_existing_custom_shortcuts", "align_summary_line", "alignments_to_pipe_delimiter", @@ -68224,7 +69944,6 @@ "applies_managed_defaults_to_a_new_thread_config", "apply_accepted_model_migration", "apply_agent_picker_thread_refresh", - "apply_agents_overview_thread_refresh", "apply_ambient_pet_image_support_override_for_tests", "apply_backend_banner_fallback", "apply_backtrack_selection", @@ -68536,7 +70255,6 @@ "beginning_of_current_line", "beginning_of_line", "beginning_of_next_word", - "beginning_of_previous_word", "best_tool_match", "big_word_range_at_cursor", "billing_surfaces_render_for_every_supported_enterprise_plan", @@ -68640,7 +70358,6 @@ "build_pet_picker_params", "build_placeholder_mapping", "build_render_lines", - "build_rows", "build_rows_preserves_full_skill_display_names", "build_search_catalog", "build_service_tier_selection_edits", @@ -68792,13 +70509,11 @@ "clear_current_draft", "clear_dismiss_after_child_accept", "clear_esc_backtrack_hint", - "clear_exec_approval_turn", "clear_for_ctrl_c", "clear_for_ctrl_c_preserves_image_draft_state", "clear_for_ctrl_c_preserves_pending_paste_history_entry", "clear_for_ctrl_c_preserves_remote_offset_image_labels", "clear_for_ctrl_c_records_cleared_draft", - "clear_for_viewport_change", "clear_guardian_review_status", "clear_hint_items", "clear_if_empty", @@ -68811,7 +70526,6 @@ "clear_notes_draft", "clear_only_ui_reset_allows_active_skill_warning_to_render_again", "clear_only_ui_reset_preserves_chat_session_state", - "clear_patch_approval_turn", "clear_pending_history_lines", "clear_pending_rate_limit_reset_hint", "clear_pending_rate_limit_reset_requests", @@ -68826,8 +70540,6 @@ "clear_remote_image_selection", "clear_remote_image_urls", "clear_removes_custom_binding", - "clear_request_permissions_turn", - "clear_request_user_input_turn", "clear_reserve_return", "clear_resets_all_input_queues", "clear_resets_stream_reflow_flags", @@ -69482,8 +71194,6 @@ "default_frame_count", "default_image_support", "default_ipc_socket_path", - "default_mask", - "default_mode_mask", "default_pager_keymap", "default_reasoning_effort_for_conversation_model", "default_unified_mention_popup_snapshot", @@ -69503,22 +71213,17 @@ "delayed_enter_after_typing_submits", "delegated_prompt", "delegated_prompts_match_desktop_xml_contract", - "delete_backward", "delete_backward_and_forward_edges", "delete_backward_keeps_thai_elements_atomic", "delete_backward_preserves_other_grapheme_behavior", "delete_backward_removes_thai_marks_one_at_a_time", - "delete_backward_word", "delete_backward_word_alt_keys", "delete_backward_word_and_kill_line_variants", "delete_backward_word_handles_narrow_no_break_space", "delete_backward_word_respects_word_separators", "delete_current_thread", "delete_diff_uses_path_extension_for_highlighting", - "delete_forward", "delete_forward_deletes_element_at_left_edge", - "delete_forward_kill", - "delete_forward_word", "delete_forward_word_alt_d", "delete_forward_word_handles_atomic_elements", "delete_forward_word_respects_word_separators", @@ -69535,7 +71240,6 @@ "deliver_page", "deliver_rolling_rate_limit_snapshot", "deliver_usage_limit_error", - "delta", "deltas_then_same_final_message_are_rendered_snapshot", "denied_event", "dense_column_text", @@ -69662,7 +71366,6 @@ "dismissed_file_popup_ignores_token_substrings_in_leading_paste", "dismissed_file_popup_tracks_token_across_leading_whitespace_edits", "dismissed_tokens_after_adjacent_elements_are_occurrence_scoped", - "dispatch_agents_overview_task", "dispatch_binding", "dispatch_cancel", "dispatch_command", @@ -69876,7 +71579,6 @@ "end_of_current_line", "end_of_line", "end_of_line_or_down_at_end_of_text", - "end_of_next_word", "end_of_next_word_from", "end_paragraph", "end_table", @@ -69901,7 +71603,6 @@ "enqueue_thread_feedback_event", "enqueue_thread_history_entry_response", "enqueue_thread_notification", - "enqueue_thread_request", "enqueueing_history_prompt_multiple_times_is_stable", "ensure_builtin_pack_for_pet", "ensure_builtin_pet", @@ -69923,7 +71624,6 @@ "ensure_slash_command_allowed_in_side_conversation", "ensure_status_indicator", "ensure_success_response", - "ensure_thread_channel", "ensure_thread_rename_allowed", "ensure_thread_usage_requested", "ensure_usage_command_available", @@ -70008,7 +71708,6 @@ "estimated_thread_usage", "event_description", "event_header_lines", - "event_is_notice", "event_issue_lines", "event_label", "event_page_lines", @@ -70016,8 +71715,6 @@ "event_survives_session_refresh", "event_table_lines", "every_possible_closer_returns_to_the_canonical_parser", - "evict_overflowing_events", - "exec_approval", "exec_approval_decision_truncates_multiline_and_long_commands", "exec_approval_emits_proposed_command_and_decision_history", "exec_approval_request", @@ -70297,7 +71994,6 @@ "filtered_dashboard_actions_use_configured_shortcuts", "filtered_items", "filtered_mentions_preserve_results_beyond_popup_height", - "filtered_presets", "filtering_long_skill_names_preserves_the_matching_row", "final_answer_completion_restores_status_indicator_for_pending_steer", "final_message_separator_hides_short_worked_label_and_includes_runtime_metrics", @@ -70415,7 +72111,6 @@ "flush_pending_history_lines", "flush_pending_paste_newline", "flush_run", - "flush_terminal_input_buffer", "flush_unified_exec_wait_streak", "fmt_elapsed_compact_formats_seconds_minutes_hours", "focus_is_notes", @@ -70469,7 +72164,6 @@ "for_method", "for_notification", "for_request", - "for_status", "for_terminal", "force_bg_to_terminal_bg", "force_color_keeps_reported_stdout_level", @@ -70494,7 +72188,6 @@ "form_request", "format_action_binding_summary", "format_additional_permissions_rule", - "format_agent_picker_item_name", "format_auto_resolution_remaining", "format_config_error", "format_config_error_preserves_server_validation_message", @@ -70629,7 +72322,6 @@ "generic_url_elicitation_resolves_without_connector_refresh", "generic_url_elicitation_snapshot", "generic_url_target", - "get", "get_available_model", "get_confirmation_prompt_height", "get_cursor_position", @@ -70730,7 +72422,6 @@ "handle_ambient_pet_image_render_error", "handle_api_key_entry_key_event", "handle_api_key_entry_paste", - "handle_app_server_event", "handle_app_server_notification", "handle_app_server_steer_rejected_error", "handle_apply_patch_approval_now", @@ -70832,9 +72523,7 @@ "handle_request_user_input_sets_pending_notification", "handle_select_key", "handle_server_notification", - "handle_server_notification_event", "handle_server_request", - "handle_server_request_event", "handle_service_tier_command_dispatch", "handle_shared_app_keymap_action", "handle_side_thread_session", @@ -70855,14 +72544,12 @@ "handle_submission_with_time", "handle_thread_event_now", "handle_thread_event_now_recovering_file_changes", - "handle_thread_event_replay", "handle_thread_item", "handle_thread_session", "handle_thread_session_quiet", "handle_token_count", "handle_transcript_loading_key", "handle_trust", - "handle_tui_event", "handle_turn_completed", "handle_turn_completed_notification", "handle_turn_interrupted", @@ -70910,8 +72597,6 @@ "has_pending_prompt", "has_pending_protected_request", "has_pending_recovery", - "has_pending_thread_approvals", - "has_pending_thread_user_input", "has_persistent_output", "has_possible_closing_line", "has_prompt_context", @@ -71130,7 +72815,6 @@ "inline_visualization_directive_survives_raw_to_rich_render_mode_switch", "inline_visualizations_use_canonical_full_render", "inline_visualizations_without_context_use_canonical_full_render", - "input", "input_disabled_ignores_keypresses_and_hides_cursor", "input_enabled", "input_height", @@ -71174,7 +72858,6 @@ "installed_connector_mentions_fall_back_for_legacy_app_servers", "installed_connector_mentions_ignore_stale_thread_workspace_and_account", "installed_connector_mentions_read_callable_apps_in_bounded_thread_scoped_batches", - "interacted_sub_agent_activity_does_not_change_liveness", "interaction_end", "interactive_request_for_thread_request", "interrupt_disambiguates_same_sized_goal_pastes", @@ -71214,7 +72897,6 @@ "invalidate_connector_scope", "invalidate_ordinary_usage_recovery", "invalidate_recovery", - "invalidate_viewport", "invalidate_viewport_repaints_default_style_spaces_over_stale_terminal_cells", "is_absolute_local_link_path", "is_absolute_url_like", @@ -71289,10 +72971,8 @@ "is_normal_backtrack_mode", "is_ordered_list_marker", "is_other_adds_none_of_the_above_and_submits_it", - "is_parent_owned", "is_pending", "is_personal_marketplace_path", - "is_plain_text_key_event", "is_plan_streaming_in_tui", "is_press", "is_press_accepts_press_and_repeat_but_rejects_release", @@ -71346,7 +73026,6 @@ "is_vim_replace_mode", "is_visible", "is_visualization_thread_dir", - "is_word_separator", "is_yolo_mode", "is_zero", "issue_url_available_for_bug_bad_result_safety_check_and_other", @@ -71428,10 +73107,7 @@ "kill_current_line_keeps_previous_newline_for_final_line", "kill_current_line_removes_current_line_linewise", "kill_line_range", - "kill_range", "kill_range_with_kind", - "kill_to_beginning_of_line", - "kill_to_end_of_line", "kill_whole_line_can_be_assigned_without_default_binding", "kill_whole_line_conflicts_until_kill_line_start_is_unbound", "kill_whole_line_keymap_dispatch_uses_linewise_kill", @@ -71444,7 +73120,6 @@ "kitty_transmit_png_file_with_id", "kitty_transmit_png_with_id", "known_thread_started_preserves_session_without_reading_unmaterialized_rollout", - "label", "label_and_refreshed_line_appear_in_order", "label_only_and_fallback_presentations_snapshot", "label_only_policy_keeps_non_web_links_and_literal_urls_unchanged", @@ -71751,19 +73426,15 @@ "mark_agent_picker_thread_closed", "mark_available", "mark_budget_limited", - "mark_closed", "mark_exhausted", "mark_failed", "mark_matching_cells", - "mark_parent_owned", "mark_primary_thread_parent_owned", "mark_recapped", "mark_reflowed_width_records_actual_rebuild_width", "mark_reflowed_width_reports_unchanged_width", "mark_replay_only", - "mark_running", "mark_safety_buffering_agent_message_started", - "mark_stopped", "mark_tui_args", "mark_underlined_hyperlink", "mark_url_hyperlink", @@ -71793,7 +73464,6 @@ "marketplace_upgrade_failure_includes_backend_messages_snapshot", "marketplace_upgrade_loading_popup_params", "marketplace_upgrade_loading_popup_snapshot", - "mask_for_kind", "masked_graphemes_align_with_the_cursor", "masked_url_input_never_exposes_hyperlink_destinations", "match_item", @@ -71855,7 +73525,6 @@ "mcp_startup_header_booting_snapshot", "mcp_startup_ignores_status_for_other_thread", "mcp_startup_notifications_route_to_threads", - "mcp_startup_notifications_without_threads_are_app_scoped", "mcp_startup_status_header", "mcp_tools_output_from_statuses_renders_status_only_servers", "mcp_tools_output_from_statuses_renders_verbose_inventory", @@ -71978,7 +73647,6 @@ "move_highlight", "move_question", "move_selected_item", - "move_selection", "move_selection_next", "move_selection_prev", "move_to_display_col_on_line", @@ -72092,8 +73760,6 @@ "newly_active_errors", "next", "next_add_to_history_event", - "next_agent_shortcut", - "next_agent_shortcut_matches", "next_agent_word_motion_fallback", "next_atomic_boundary", "next_available_action", @@ -72106,7 +73772,6 @@ "next_history_offset", "next_interrupt_op", "next_large_paste_placeholder", - "next_mask", "next_rate_limits_loaded", "next_reasoning_effort", "next_reasoning_effort_clamps_at_bounds", @@ -72187,15 +73852,12 @@ "normalizes_generated_title_whitespace", "note_active_thread_outbound_op", "note_catch_up_exit", - "note_evicted_server_request", "note_focus_gained", "note_focus_lost", "note_history_rows_inserted", "note_outbound_op", "note_plain_char", "note_rendered_width", - "note_server_notification", - "note_server_request", "note_stream_consolidation_completed", "note_stream_consolidation_queued", "note_thread_outbound_op", @@ -72229,7 +73891,6 @@ "older_external_server_starts_without_unsupported_dynamic_tools_or_history", "older_pagination_reconciles_review_prompts_across_page_boundaries", "oldest_queued_age", - "omit_completed_agent_deltas", "omit_resolved_misalignment_errors", "on_account_login_completed", "on_account_updated", @@ -72355,7 +74016,6 @@ "op_can_change_state", "open", "open_advanced_reasoning_popup", - "open_agent_picker", "open_agent_picker_allows_existing_agent_threads_when_feature_is_disabled", "open_agent_picker_clears_running_hint_from_completed_snapshot", "open_agent_picker_keeps_missing_threads_for_replay", @@ -72367,7 +74027,6 @@ "open_agent_picker_prunes_terminal_metadata_only_threads", "open_agent_picker_refreshes_replay_only_path_backed_liveness", "open_agent_picker_selects_path_backed_agent", - "open_agents_overview", "open_all_models_popup", "open_all_models_popup_with_view_id", "open_app_link_view", @@ -72466,9 +74125,7 @@ "ordered_item_continuation_paragraph_is_indented", "ordered_item_with_code_block_and_nested_bullet", "ordered_item_with_indented_continuation_is_tight", - "ordered_path_backed_subagent_threads", "ordered_thread_ids", - "ordered_threads", "ordinary_redraws_with_known_size_do_not_query_backend_size", "osc52_encoding_roundtrips", "osc52_rejects_payload_larger_than_limit", @@ -72665,7 +74322,6 @@ "pasted_image_format_with_windows_style_paths", "pasted_text_normalizes_mixed_line_endings_at_app_boundary", "pasting_filepath_attaches_image", - "patch_approval", "patch_approval_lookup_keeps_turn_and_item_identity", "patch_approval_request", "patch_approval_request_from_params", @@ -72812,7 +74468,6 @@ "picker_runtime_keymap", "picker_selected_action_starts_on_matching_all_tab_row", "picker_shows_fast_mode_action_when_feature_is_enabled", - "picker_subtitle", "picker_subtitle_mentions_shortcuts", "picker_unbound_tab_lists_default_unbound_actions", "picker_wide_render_snapshot", @@ -72856,7 +74511,6 @@ "plan_implementation_popup_skips_without_proposed_plan", "plan_implementation_popup_snapshot", "plan_implementation_popup_yes_emits_submit_message_event", - "plan_mask", "plan_mode_prompt_notification_uses_dedicated_type_name", "plan_mode_reasoning_override_is_marked_current_in_reasoning_popup", "plan_reasoning_scope_popup_all_modes_persists_global_and_plan_override", @@ -73013,7 +74667,6 @@ "pop_next_queued_user_message", "pop_user_input_request_for_turn", "populate_history_at_index", - "populated_state", "popup_active", "popup_content_width", "popup_name_truncation_counts_halfwidth_sound_marks", @@ -73119,8 +74772,6 @@ "preview_uses_placeholders_when_runtime_values_are_missing", "preview_uses_runtime_values", "previous", - "previous_agent_shortcut", - "previous_agent_shortcut_matches", "previous_agent_word_motion_fallback", "previous_available_action", "previous_buffer", @@ -73164,7 +74815,6 @@ "prompt_edit_thread_history_line_snapshot", "prompt_for", "prompt_glyph", - "prompt_line", "prompt_skip_hint", "prompt_snapshot", "prompt_snapshot_gpt5_codex", @@ -73196,7 +74846,6 @@ "push_ascii", "push_binding_for_action", "push_blank_line", - "push_buffered_event", "push_delta", "push_elicitation", "push_exec_approval", @@ -73214,15 +74863,11 @@ "push_local_link_label_break", "push_local_link_label_span", "push_mcp_server_elicitation_request", - "push_notification", "push_notification_inner", - "push_notification_ref", "push_output_line", "push_owned_lines", "push_prefixed_value_line", "push_prewrapped_line", - "push_replay_notification", - "push_request", "push_request_permissions", "push_run", "push_section_header", @@ -73399,6 +75044,7 @@ "read_session_cwd", "read_session_model", "read_startup_probe", + "read_thread", "read_tmux_extended_keys_format", "read_until", "read_version_info", @@ -73446,7 +75092,6 @@ "reasoning_up_shortcut_does_not_silently_enter_advanced_effort", "reasoning_up_shortcuts_raise_reasoning_effort", "reassignable_fixed_shortcuts_conflict_until_original_action_is_unbound", - "rebase_buffer_after_session_refresh", "rebuild_config_for_cwd", "rebuild_config_for_permission_profile", "rebuild_config_for_resume_or_fallback", @@ -73517,7 +75162,6 @@ "record_replayed_submission", "record_replayed_user_message_history", "record_safety_buffering_turn", - "record_sub_agent_activity", "record_vim_inserted_text", "record_world_writable_scan_result", "recorded_params", @@ -73540,9 +75184,7 @@ "refresh_agent_picker_thread_liveness", "refresh_agent_picker_thread_liveness_prunes_closed_metadata_only_threads", "refresh_agent_picker_threads", - "refresh_agents_overview_threads", "refresh_backend_banner_visibility", - "refresh_changed_agents_overview_threads", "refresh_connector_directory_after_notification", "refresh_connector_mentions", "refresh_connectors", @@ -73705,13 +75347,9 @@ "remote_tmux_environment", "remote_turn_permissions_preserve_active_profile_selection", "remotely_resolved_startup_approvals_release_the_draft_after_the_last_request", - "remove", - "remove_call_id_from_turn_map", - "remove_call_id_from_turn_map_entry", "remove_deleted_local_placeholders", "remove_element_range", "remove_legacy_tui_log_file", - "remove_request", "remove_resolved_prompt", "remove_resolved_prompt_keeps_lifecycle_events", "remove_resolved_prompt_matches_exec_approval_id", @@ -73754,7 +75392,6 @@ "render_debug_config_lines", "render_dense_row_snapshot", "render_dense_session_lines", - "render_details", "render_device_code_login", "render_display_lines", "render_empty_state_line", @@ -73913,7 +75550,6 @@ "renders_without_spinner_when_animations_disabled", "renders_wrapped_details_panama_two_lines", "reopening_identical_unified_mention_restarts_file_search", - "repaint_agents_overview", "repeat_aborts_when_change_motion_cannot_start", "repeat_distinguishes_insert_mode_deletions_from_noops", "repeat_omits_ineffective_deletions_before_inserted_text", @@ -73979,7 +75615,6 @@ "replay_only_thread_keeps_restored_queue_visible", "replay_snapshot_with_pending_request_suppresses_replay_notices", "replay_thread_item", - "replay_thread_snapshot", "replay_thread_snapshot_does_not_submit_queue_before_replay_catches_up", "replay_thread_snapshot_in_progress_turn_restores_running_queue_state", "replay_thread_snapshot_in_progress_turn_restores_running_state_without_input_state", @@ -74129,7 +75764,6 @@ "reset_transcript_state_after_clear", "reset_turn_flags", "reset_vim_mode_after_successful_dispatch", - "resize", "resize_draw_applies_event_dimensions_without_querying_backend_size", "resize_reflow_max_rows", "resize_reflow_max_rows_for", @@ -74195,7 +75829,6 @@ "restore_draft", "restore_env", "restore_input_mode", - "restore_keep_raw", "restore_keyboard_enhancement_stack", "restore_locked", "restore_pre_review_token_info", @@ -74250,7 +75883,6 @@ "resume_source_kinds", "resume_status_timer_after_modal", "resume_table_snapshot", - "resume_target_session", "resume_text_entry", "resumed_initial_messages_render_history", "resumed_session_hides_unknown_token_usage_until_an_update_arrives", @@ -74333,7 +75965,6 @@ "row_separator_height", "row_tail_is_blank", "row_to_pipe_line", - "rows", "rows_do_not_exceed_width_ascii", "rows_do_not_exceed_width_emoji_cjk", "rows_from_matches", @@ -74462,7 +76093,6 @@ "scrolled_transcript_renderables_match_full_height_fallback", "scrolled_url_fragments_keep_the_offscreen_destination", "scrolling_mentions_shifts_rendered_window_snapshot", - "search", "search_batch_absent_1024_uses_one_single_and_eight_batches", "search_batch_late_data_is_cache_only_after_cancel_or_query_edit", "search_batch_match_preserves_cursor_for_next_older_search", @@ -74496,7 +76126,6 @@ "select", "select_agent_thread", "select_agent_thread_and_discard_side", - "select_agents_overview_thread", "select_cached_unique_match", "select_current_option", "select_first_enabled_row", @@ -74528,7 +76157,6 @@ "selected_event_rows_use_the_shared_accent_style", "selected_hook", "selected_hook_index", - "selected_index", "selected_index_for_active_view", "selected_index_for_present_view", "selected_item", @@ -74540,7 +76168,6 @@ "selected_mention", "selected_option_index", "selected_render_line_index", - "selected_row", "selected_rows_use_the_shared_accent_style", "selected_session_style", "selected_session_title_span", @@ -74574,9 +76201,7 @@ "server_account_requirement_controls_login_screen", "server_feature", "server_name", - "server_notification_thread_target", "server_overloaded_error_does_not_switch_models", - "server_request_thread_id", "server_version", "service_tier_command_uses_catalog_name_and_description", "service_tier_commands_are_hidden_when_disabled", @@ -74630,7 +76255,6 @@ "set_active_reasoning_effort", "set_active_reasoning_effort_baseline", "set_active_turn_id", - "set_agent_path", "set_agents_navigation_enabled", "set_all_enabled", "set_alt_screen_enabled", @@ -74721,7 +76345,6 @@ "set_metadata", "set_model", "set_model_updates_active_collaboration_mask", - "set_modes", "set_notification", "set_notification_settings", "set_panic_hook", @@ -74761,7 +76384,6 @@ "set_remote_image_urls", "set_render_mode", "set_resume_action", - "set_running", "set_search_query", "set_selected_session", "set_service_tier", @@ -74815,7 +76437,6 @@ "set_tui_theme", "set_turns", "set_unified_exec_processes", - "set_viewport_area", "set_vim_enabled", "set_width", "set_windows_degraded_sandbox_active", @@ -74895,7 +76516,6 @@ "should_refresh_snapshot_session", "should_reject_side_backtrack_esc", "should_render_link_destination", - "should_replay_snapshot_request", "should_show_bedrock_setup_wizard", "should_show_delayed_hint", "should_show_fast_status", @@ -75184,7 +76804,6 @@ "slash_with_leading_space_submits_as_text", "small_word_range_at_cursor", "smooth_mode_is_default", - "snapshot", "snapshot_auto_all_rows_col_width_mode_scroll_behavior", "snapshot_auto_visible_col_width_mode_scroll_behavior", "snapshot_buffer", @@ -75199,7 +76818,6 @@ "snapshot_footer_with_indicators", "snapshot_footer_with_mode_indicator", "snapshot_footer_with_mode_indicator_and_context", - "snapshot_has_pending_interactive_request", "snapshot_lines", "snapshot_lines_text", "snapshot_mention_bindings", @@ -75239,7 +76857,6 @@ "spawn_pet_load", "spawn_plugin_enabled_write", "spawn_request_spans", - "spawn_request_summary", "spawn_search", "spawn_startup_thread_start", "spawn_world_writable_scan", @@ -75262,7 +76879,6 @@ "split_plugin_name_segments", "split_reasoning_summary_parts", "split_request_user_input_answer", - "split_word_pieces", "sprite_indices", "srgb_to_linear", "ssh_inside_tmux_falls_back_to_osc52_when_tmux_copy_fails", @@ -75294,7 +76910,6 @@ "start_active_goal_turn", "start_add_credits_nudge_email_request", "start_agents_daemon", - "start_agents_overview_refresh", "start_and_finish_update_running_state", "start_api_key_entry", "start_app_server", @@ -75341,7 +76956,6 @@ "start_temporary_thread", "start_test_embedded_app_server", "start_thread_with_request_handle", - "start_thread_with_session_start_source", "start_vim_edit", "start_vim_find", "started_thread_from_fork_response", @@ -75608,7 +77222,6 @@ "step", "step_backtrack_and_highlight", "step_forward_backtrack_and_highlight", - "stop_agents_overview_thread", "stop_command_is_canonical_name", "stop_command_resolves_for_dispatch", "stop_rate_limit_poller", @@ -75665,10 +77278,8 @@ "styled_span", "styled_split_within_span_preserves_style", "styled_text_rendering_does_not_overwrite_cells_outside_the_textarea", - "sub_agent_activity_display", "sub_agent_activity_history_cell", "sub_agent_activity_item", - "sub_agent_activity_summary", "sub_agent_activity_title", "subagent_mcp_startup_settles_while_cached_servers_remain_deferred", "subject", @@ -75678,7 +77289,6 @@ "submission_preserves_text_elements_and_local_images", "submission_with_remote_and_local_images_keeps_local_placeholder_numbering", "submit_active_thread_op", - "submit_agents_overview_prompt", "submit_answers", "submit_at_character_limit_succeeds", "submit_captures_recent_mention_bindings_before_clearing_textarea", @@ -75749,6 +77359,7 @@ "suppresses_printable_quit_key_during_api_key_entry", "suppresses_stderr_only_while_terminal_is_owned", "surface_pending_inactive_thread_interactive_requests", + "suspend", "suspend_process", "swap_buffers", "swap_vim_persistent_state", @@ -76024,7 +77635,6 @@ "theme_scope_backgrounds_quantize_to_ansi256", "theme_warning_uses_final_config", "thread_approve_guardian_denied_action", - "thread_archive", "thread_background_terminals_clean", "thread_blocks_direct_input", "thread_closed", @@ -76045,7 +77655,6 @@ "thread_event_snapshot_keeps_newer_request_user_input_pending_when_same_turn_has_queue", "thread_event_snapshot_keeps_pending_request_user_input", "thread_event_store_clear_active_turn_id_resets_cached_turn", - "thread_event_store_coalesces_only_adjacent_matching_agent_message_deltas", "thread_event_store_preserves_recap_progress_across_replay", "thread_event_store_rebase_preserves_hook_notifications", "thread_event_store_rebase_preserves_mcp_startup_notifications", @@ -76073,7 +77682,6 @@ "thread_lifecycle_params_forward_config_overrides_and_service_tier", "thread_lifecycle_params_forward_explicit_remote_cwd_override_for_remote_sessions", "thread_lifecycle_params_omit_cwd_without_remote_override_for_remote_sessions", - "thread_list", "thread_list_params", "thread_loaded_list", "thread_memory_mode_set", @@ -76082,7 +77690,6 @@ "thread_name", "thread_name_suggestion_requires_matching_thread_and_request", "thread_params_mode", - "thread_read", "thread_read_fallback_uses_active_permission_settings", "thread_read_session_state_does_not_reuse_primary_permission_profile", "thread_recap_loading_without_animations_snapshot", @@ -76093,7 +77700,6 @@ "thread_session_state_from_thread_response", "thread_session_state_from_thread_resume_response", "thread_session_state_from_thread_start_response", - "thread_set_name", "thread_setting_update_params_sync_model_and_default_reasoning", "thread_settings_for_test", "thread_settings_update", @@ -76126,7 +77732,6 @@ "thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled", "thread_tool_transport", "thread_turns_page", - "thread_unarchive", "thread_unavailable", "thread_unsubscribe", "thread_usage", @@ -76175,7 +77780,6 @@ "tmux_should_enable_modify_other_keys_for", "to_result", "toggle_action_sends_set_app_enabled_and_updates_label", - "toggle_density", "toggle_enabled", "toggle_fast_mode_can_be_assigned_without_default_binding", "toggle_fast_mode_conflicts_with_existing_main_surface_bindings", @@ -76210,7 +77814,6 @@ "tokens_in_context_window", "tool_approval_display_params_prefer_explicit_display_order", "tool_approval_meta", - "tool_call_history_cell", "tool_specs", "tool_suggestion", "tool_suggestion_meta_is_parsed_into_request_payload", @@ -76220,12 +77823,10 @@ "total_duration", "total_entries", "total_lines", - "track_agents_overview_notification", "track_in_flight_recap", "track_unified_exec_output_chunk", "track_unified_exec_process_begin", "track_unified_exec_process_end", - "tracked_thread_ids", "trailing_run_start", "trailing_separator_trimming_preserves_local_roots", "trailing_spaces_wrap_without_letting_the_cursor_escape", @@ -76315,7 +77916,6 @@ "try_draw_with_size", "try_dump_windows_clipboard_image", "try_handle_shortcut", - "try_list_models", "try_resolve_app_server_request", "try_set_approval_policy_on_config", "try_set_builtin_active_permission_profile_on_config", @@ -76324,10 +77924,8 @@ "tui_mut", "turn", "turn_complete_keeps_unified_exec_processes", - "turn_completed", "turn_completed_notification", "turn_id_matches", - "turn_interrupt", "turn_permission_profile", "turn_permissions_override_from_config", "turn_permissions_overrides", @@ -76335,7 +77933,6 @@ "turn_permissions_preserve_thread_permissions_without_override", "turn_permissions_send_legacy_sandbox_for_local_override", "turn_permissions_use_active_profile_when_available", - "turn_start", "turn_start_failure_is_shown_without_exiting", "turn_start_keeps_an_outstanding_approval_paused", "turn_start_preserves_active_mcp_startup_header", @@ -76496,12 +78093,9 @@ "update_status", "update_task_running_state", "update_thread_goal", - "update_viewport", "update_visible_history_rows", "uppercase_commands_accept_shift_only_terminal_events", - "upsert", "upsert_agent_picker_thread", - "upsert_preserves_first_seen_order", "upsert_thread", "url_hyperlinks_preserve_existing_highlight_styles", "url_link_shows_destination", @@ -76732,7 +78326,6 @@ "vt100_user_message_url_wrap_preserves_gutter_and_background", "vt100_zellij_raw_insert_keeps_soft_wrapped_tail_above_viewport", "vt100_zellij_raw_replay_keeps_overflowing_soft_wrapped_tail_above_viewport", - "wait_complete_lines", "wait_for_fd_ready", "wait_for_file", "wait_for_ready", @@ -76747,7 +78340,6 @@ "warn_invalid_terminal_title_items_once", "warning_event_adds_warning_history_cell", "warning_notifications_route_to_threads_when_thread_id_is_present", - "warning_notifications_without_threads_are_global", "warns_for_read_only", "warns_when_profile_can_write_elsewhere_but_not_cwd", "wave_only_paints_while_a_sweep_is_crossing_the_composer", @@ -76807,7 +78399,6 @@ "with_border_internal", "with_border_with_inner_width", "with_chat_widget_frame", - "with_collaboration_modes", "with_height", "with_initial_and_subsequent_indents", "with_options", @@ -76816,7 +78407,6 @@ "with_pending_and_remote", "with_remote_cwd_override", "with_renderables", - "with_restored", "with_screen_size_and_cursor_position", "with_screen_size_and_cursor_position_for_test", "with_scrollback", @@ -76961,7 +78551,6 @@ "wt_session_promotes_ansi16_to_truecolor_for_diffs", "wt_session_promotes_unknown_color_level_to_truecolor", "xyz_to_lab", - "yank", "yank_current_line", "yank_line_range", "yank_range", @@ -76972,6 +78561,11 @@ "zellij_only_uses_full_screen_insertion_for_terminal_wrapped_history" ], "symbolNamesOnlyInLime": [ + "AgentPicker", + "AgentPickerAction", + "AgentPickerEntry", + "AgentsOverviewAction", + "AgentsOverviewInputMode", "AppAction", "AppServerResponse", "CodeLineHighlighter", @@ -76983,6 +78577,8 @@ "DiffLineKind", "EntryKind", "EntryStatus", + "EventContext", + "EventDispatch", "ExecOptions", "ExecResult", "ListKind", @@ -76990,32 +78586,53 @@ "ModelPicker", "ModelPickerAction", "ModelSelection", + "MultiAgentHistoryCell", "PagerAction", "PagerOverlay", "PendingInteraction", "PickerAction", "PickerLoadEvent", "PickerTranscriptPager", + "ReconnectedSession", "Renderer", "SettingsCommand", "StatusFacts", "TableRow", - "TerminalGuard", "ThreadPage", "TranscriptEntry", "TuiOptions", - "TuiTerminal", + "ViewportState", "active_bottom_pane_receives_input_before_the_chat_composer", "active_permission_profile", "active_permission_profile_requires_a_non_empty_id", "active_turn_elapsed", "active_turn_status_does_not_overflow_a_tiny_terminal", "active_turn_status_precedes_canonical_queue_and_composer", + "activity", "adaptive_wrap_keeps_url_token_intact", "add_notes", + "adjacent_agent", + "agent_picker_empty", + "agent_picker_footer", + "agent_picker_title", + "agents_overview_buffers_latest_notification_during_refresh", + "agents_overview_controls_cover_all_product_locales", + "agents_overview_footer", + "agents_overview_group_bubbles_child_status_and_hides_ephemeral_threads", + "agents_overview_group_label", + "agents_overview_input_prefix", + "agents_overview_refresh_coalesces_requests_and_exposes_codex_state_fields", + "agents_overview_refresh_ignores_stale_generation", + "agents_overview_refresh_retains_locally_observed_threads", + "agents_overview_search_prefix", + "agents_overview_task_statuses_cover_all_product_locales", + "agents_overview_title", + "agents_slash_command_opens_the_agents_overview", "aliases_resolve_to_extended_syntaxes", + "alt_right_switches_to_the_next_agent_in_spawn_order", "alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit", "alt_up_requests_server_delete_before_restoring_the_last_queued_input", + "alternate_screen_round_trip_restores_inline_viewport", "an_open_popup_owns_escape_before_active_turn_interruption", "ansi256_palette_contains_cube_and_grayscale", "ansi_alpha_markers_map_to_terminal_colors", @@ -77023,6 +78640,7 @@ "append_item_preview_lines", "append_prompt_history", "apply_notification", + "apply_refresh", "apply_thread_page", "apply_threads", "approval_hides_an_open_slash_command_popup", @@ -77032,27 +78650,42 @@ "archive_failure_preserves_selected_thread_and_allows_retry", "archive_request_is_deduplicated_until_completion", "archive_success_removes_thread_and_clamps_selection", - "archive_thread", "assistant_item", "assistant_markdown_is_rendered_at_the_transcript_boundary", "attach", "attachments_are_ordered_and_restorable", + "background_thread_notifications_do_not_mutate_the_current_projection", + "backtab_cycles_server_collaboration_modes_only_when_idle", "bare_urls_are_linked_but_code_and_unsafe_schemes_are_not", "begin_load", + "begin_refresh", "blank_line", "borrowed_range", "bounded_command_output_lines", + "can_accept_direct_input", "can_restore_submission", "cancel_response", + "capture_current_thread_input", "changed_width_is_debounced_and_due_is_observable", + "clear_exec_approval_item", + "clear_patch_approval_item", "client_message_id", + "closing_thread_clears_all_prompt_categories", + "codex_agents_defaults_are_stable", "codex_buffer_replacement_preserves_cursor_only_at_valid_boundaries", "codex_cursor_pos_api_keeps_cursor_visible_in_a_scrolled_viewport", + "codex_input_dispatches_emacs_style_editor_bindings", "codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft", + "codex_word_boundaries_handle_cjk_and_separator_runs", + "codex_word_delete_and_line_kill_preserve_unicode_boundaries", "collab_agent_status_label", "collab_agent_summary_keeps_requested_model_effort_and_prompt", "collab_entry_status", + "collab_resume_projection_keeps_resume_result", "collab_summary", + "collab_summary_for_item", + "collab_wait_projection_keeps_per_agent_results", + "collaboration_mode_statuses_cover_all_product_locales", "collect_metrics", "collects_option_and_freeform_questions_before_responding", "color_distance", @@ -77075,6 +78708,7 @@ "compact_text", "comparable_path", "complete_slash_command", + "completed_agent", "completed_agent_item_replaces_streaming_delta", "completed_command_item_replaces_live_output_with_canonical_output", "configure_external_editor", @@ -77084,6 +78718,8 @@ "constrained_tables_wrap_without_exceeding_available_width", "content_spans", "continuation_style", + "control", + "control_and_alt_characters_are_not_plain_text", "copy_last_response_with", "copy_shortcut_and_slash_command_do_not_become_turn_input", "copy_uses_last_canonical_agent_markdown_and_reports_outcome", @@ -77097,6 +78733,7 @@ "ctrl_t_opens_a_shared_transcript_pager_and_starts_loading_when_needed", "ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state", "current_transcript_page_size", + "cursor_anchor", "cursor_and_backspace_respect_extended_graphemes", "cwd_label", "cycle_permission_profile", @@ -77113,23 +78750,34 @@ "diff_keeps_independent_multiline_syntax_state_for_old_and_new_files", "diff_uses_file_extension_for_syntax_highlighting", "disconnected_edit_preserves_draft_and_cancels_history_preview", + "disconnected_editing_preserves_graphemes_and_cursor_boundaries", "disconnected_enter_and_tab_leave_input_available_for_reconnect", + "disconnected_input_edits_locally_without_submit_or_queue", + "disconnected_paste_and_ctrl_c_are_handled_at_the_app_boundary", + "disconnected_repeat_key_is_editable_but_never_submits", "discovers_punctuated_and_balanced_web_urls", + "dispatch_connected_input", + "dispatch_disconnected_input", "display_change_path", "display_path", "down_selects_session_decision", + "drain_snapshot", "dump_windows_clipboard_image", "dynamic_entry_status", "dynamic_summary", + "each_interactive_request_is_pending_until_resolved", "edit_draft", "edit_draft_with_command", "edit_hint_requires_a_lossless_composer_projection", "edit_queued_input_hint", "editor_failure_is_returned_without_leaking_the_tempfile", + "editor_word_and_kill_shortcuts_share_textarea_semantics", "edits_draft_after_closing_the_tempfile_handle", "effort_label", "elapsed_time_matches_codex_compact_format", + "elicitation", "ellipsis_inherits_the_last_visible_span_style", + "empty_catalog_has_no_mode", "empty_notes_submit_the_selected_option", "empty_queries_and_missing_selection_do_not_match", "empty_threads_produce_an_empty_canonical_transcript", @@ -77137,10 +78785,13 @@ "end", "ensure_prefix", "enter_accepts_the_selected_command_decision", + "enter_alternate_screen", "enter_executes_immediate_commands_and_completes_argument_commands", "escape_cancels_instead_of_approving", "escape_interrupts_only_an_active_turn_and_preserves_the_draft", "escape_returns_an_empty_fail_closed_response", + "event_is_notice_matches_warning_notifications", + "evicted_request_does_not_replay", "expanded_transcript_uses_canonical_entries_and_stays_bounded", "exposes_all_product_locale_tags", "fail_closed", @@ -77170,17 +78821,21 @@ "finish_link", "finish_row", "finish_table", + "first_viewport_change_clears_from_new_area_when_old_area_is_empty", "first_width_sets_baseline_without_reflow", "fixed_elapsed_test_backend_matches_wide_and_narrow_layouts", + "footer_renders_the_active_agent_label", "for_composer", "forced_width_hyperlinks_preserve_wide_and_halfwidth_cells", "format_line", "format_patch", "from_name", + "from_navigation", "from_server_request", - "handle_terminal_event", + "handle_transcript_pager_event", "hard_wrap", "hard_wrap_spans", + "has_more_pages", "has_pending_images", "highlight_line", "highlighter_for", @@ -77200,21 +78855,32 @@ "image_label", "image_shortcut_attaches_and_allows_image_only_submission", "initialize_params", + "inline_viewport_shrinks_into_terminal_bounds", + "inline_viewport_stays_bottom_aligned_when_terminal_grows", + "input_mode", "insert", "install_panic_hook", "interrupt_hint", + "interrupted_sub_agent_activity_stops_running_liveness", "invalid_percent_encoding_stays_visible", "invalidate_thread_list", + "invalidate_viewport_sets_repaint_marker", "is_absolute_path", "is_no_active_turn_error", + "is_searching", "is_table_start", "is_transcript", "item_kinds_have_distinct_terminal_layouts", "item_result_fields_become_structured_display_summaries", + "item_started_clears_command_and_file_approval_by_item_id", "keeps_link_destinations_visible_and_underlined", "key", + "key_label", + "key_release_does_not_insert_or_submit", "language_for_path", "languages_for_change_path", + "leave_alternate_screen", + "leaving_alternate_without_saved_area_is_a_noop", "len", "line_end", "line_navigation_stays_inside_the_current_logical_line", @@ -77223,10 +78889,10 @@ "lines_with_locale", "lines_with_locale_unbounded", "lines_with_locale_with_width", + "list_collaboration_modes", "list_models", "list_permission_profiles", "list_queued_submissions", - "list_thread_page", "load_history", "load_session_transcript_with_handle", "load_thread_page_with_handle", @@ -77240,12 +78906,15 @@ "map_owned_range", "maps_windows_temp_paths_for_wsl", "mark_preview_loading", + "mask", "matching_path_labels_collapse_but_descriptive_labels_remain", "mcp_entry_status", "mcp_summary", "merge_canonical_items", "metadata_line", + "mode_selection_prefers_default_and_cycles_in_server_order", "model", + "model_catalog_preserves_server_order", "model_label", "model_route_requires_model_and_provider_together", "modified_enter_adds_a_newline_and_plain_enter_submits", @@ -77253,6 +78922,9 @@ "move_line_end", "move_line_start", "move_right", + "multi_agent", + "multi_agent_titles_are_localized_at_the_render_boundary", + "multi_agent_titles_cover_all_product_locales", "multiline_python_preserves_content", "multiline_wrap_returns_owned_lines", "multiple_hunks_use_a_vertical_ellipsis_instead_of_protocol_headers", @@ -77262,16 +78934,25 @@ "nearest_ansi256_color_is_indexed", "nearest_char_boundary", "nested_lists_keep_a_scannable_hierarchy", + "new_task", + "next_collaboration_mode", "next_grapheme_end", + "next_grapheme_end_at", "next_page_is_hidden_while_loading", "no_questions", "non_breaking_spaces_stay_inside_the_word", + "non_empty", "non_interactive_responses_fail_closed_for_every_supported_interaction", "normalize_hash_location", "normalize_line_column", + "normalize_paste", "normalize_path", "not_set_label", + "note_outbound_response", + "observe_item", + "observe_notification", "older_and_newer_matches_are_case_insensitive_and_bounded", + "omit_completed_agent_deltas_removes_only_replaced_streams", "one_column_wrap_uses_a_visible_placeholder_for_wide_graphemes", "only_safe_web_destinations_receive_osc8", "open_model_picker", @@ -77286,9 +78967,17 @@ "other_option", "other_option_enabled", "other_option_is_only_added_when_the_contract_enables_it", + "outbound_response_clears_only_the_matching_request", "output_omitted_bytes", "output_omitted_lines", "oversized_input_falls_back_without_highlighting", + "overview_codex_shortcuts_dispatch_rename_stop_and_resume", + "overview_escape_and_refresh_are_explicit_actions", + "overview_groups_and_selects_current_thread", + "overview_rename_accepts_shifted_characters", + "overview_render_is_bounded_on_wide_and_narrow_terminals", + "overview_render_shows_localized_new_task_input_without_overflow", + "overview_search_filters_and_enter_selects", "own_lines", "owned_line", "page_navigation_matches_codex_shape_and_reaches_the_next_cursor", @@ -77313,8 +79002,11 @@ "persist_prompt", "persist_temp_png", "persisted_items_share_the_live_projection_shape", + "picker", "picker_controls_match_codex_resume_shortcut_shapes", + "picker_copy_and_status_render_for_every_product_locale", "picker_empty", + "picker_escape_cancels_and_narrow_render_stays_bounded", "picker_escape_cancels_and_render_stays_bounded", "picker_filters_ephemeral_threads_and_navigates", "picker_filters_hidden_models_and_selects_default_first", @@ -77322,10 +79014,13 @@ "picker_only_reloads_for_query_mutations", "picker_render_is_bounded_for_narrow_unicode_terminal", "picker_search_filters_preview_name_path_and_thread_id", + "picker_selects_rows_in_stable_navigation_order", + "picker_subtitle_mentions_codex_shortcuts", "picker_title", "plain", "plan_and_diff_notifications_replace_stable_projection_entries", "plan_marker", + "plan_mode", "plan_statuses_use_stable_checkbox_styles", "pop_style", "preserves_multiline_and_empty_input", @@ -77366,12 +79061,12 @@ "recognizes_unix_windows_unc_and_file_url_paths", "reconnect_session", "refresh_queued_submissions", - "reject_raw_server_request", "rejects_unterminated_quotes", "remove_last", "remove_last_pending_image", "remove_next_grapheme", "remove_previous_grapheme", + "rename", "rename_uses_destination_extension_for_syntax_highlighting", "render_composer", "render_markdown_lines_with_width", @@ -77385,16 +79080,21 @@ "renders_encoded_paths_and_location_suffixes_without_filesystem_access", "renders_headings_emphasis_code_lists_quotes_and_links", "renders_tables_as_aligned_terminal_columns", + "repeated_alternate_entry_preserves_original_inline_area", "replace", "replace_composer", "replace_entry", "replace_text", + "replace_threads", "request_unarchive_for_selected_session", + "request_user_input", "required_test_path", "reset_discards_cursor_and_scan_progress", "reset_history_navigation", + "resize_keeps_non_bottom_aligned_viewport_position", "resize_recomputes_scroll_bounds_and_tiny_areas_do_not_panic", "resolve_editor_executable", + "resolved", "respond", "respond_current_time", "response_for_selected", @@ -77403,6 +79103,7 @@ "restore_queued_submission_for_edit", "restore_request_is_deduplicated_and_returns_canonical_target", "restore_terminal_state", + "restore_thread_input", "resume_controls_hint", "resume_density_label", "resume_empty", @@ -77412,8 +79113,10 @@ "resume_label", "resume_loading", "resume_picker_controls_cover_all_product_locales", + "resume_picker_owns_input_until_cancelled", "resume_picker_title", "resume_search_placeholder", + "resume_slash_command_opens_the_shared_picker_action", "resume_sort_label", "resume_status_label", "resume_title", @@ -77422,6 +79125,7 @@ "resume_transcript_hint", "resume_transcript_loading", "rgba_clipboard_pixels_encode_as_png", + "row", "run_exec", "run_exec_with_config", "run_exec_with_session", @@ -77444,9 +79148,12 @@ "selected_option_label", "selected_rows_use_codex_marker_and_bold_cyan_style", "selected_thread_id", + "server_requests_expose_their_thread_target", + "server_resolution_clears_request_identity_and_category", "session_id", "session_picker_action_keeps_resume_and_fork_selection_distinct", "session_target_uses_thread_id_when_path_is_not_available", + "set_collaboration_modes", "set_cwd", "set_locale", "set_permission_profiles", @@ -77455,16 +79162,20 @@ "set_thread_id", "set_transcript", "set_transcript_preview", + "settings_updates_keep_the_active_collaboration_mode_in_sync", "settle_running_entries", + "shifted_characters_are_plain_text", "shortcuts_cycle_codex_order_and_recover_unknown_values", "should_load_more", "slash_command_description", "slash_command_descriptions_cover_all_product_locales", "slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes", "slash_popup_filters_and_executes_immediate_commands", + "snapshot_has_pending_interactive_request_matches_user_input", "soft_break_spaces_hang_on_the_previous_row", "spawn_archive_request", "spawn_preview_load", + "spawn_request_summary_preserves_model_and_effort", "spawn_thread_load", "spawn_transcript_load", "spawn_unarchive_request", @@ -77477,27 +79188,37 @@ "start_turn_input", "start_turn_records_active_turn_from_request_response", "state_label", + "stateful_render_clears_rows_outside_the_current_draft", "static_overlay_wraps_scrolls_jumps_and_closes", "status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed", + "status_grouping", "status_line", "status_overlay_localizes_an_empty_projection_status_as_ready", "status_overlay_uses_current_values_without_a_second_session_model", "status_pager_labels_cover_all_product_locales", "status_pager_owns_the_frame_and_renders_current_session_facts", + "status_summary_text", "status_text", "status_title", "stdio_config", "stdio_config_appends_host_arguments_after_the_current_runtime_default", "steer_turn_input", + "stop", + "stopped_activity_does_not_revive_after_liveness_terminal_state", "stream_reflow_request_is_drained_once", "strips_osc8_without_changing_visible_text", "styles", - "sub_agent_status", + "sub_agent_activity_updates_navigation_liveness_and_label", + "subagent_activity_marks_the_thread_parent_owned_before_resume", + "subagents_slash_command_opens_the_codex_named_picker", "submission", "submission_input", "submission_input_keeps_codex_image_then_text_order", "submission_preview_lines", "submission_summary", + "sync_default_collaboration_mode", + "sync_view_state", + "sync_viewport", "syntax_highlighted_diff_wraps_without_losing_text_or_width", "systemic_compact_fragmentation_uses_stacked_records", "tab_queues_a_follow_up_without_submitting_the_active_turn", @@ -77506,10 +79227,13 @@ "tabs_and_blank_context_lines_preserve_diff_geometry", "tag", "take_pending_images", + "take_refresh_pending", "take_submission", + "take_thread_event_snapshot", "temporary_clipboard_image_uses_png_suffix_and_bytes", "terminal_copy", "terminal_mut", + "terminal_screen_text", "test_backend_marks_markdown_links_with_osc8", "test_backend_renders_bounded_command_output_marker", "test_backend_renders_canonical_queue_between_transcript_and_composer", @@ -77528,15 +79252,27 @@ "textwrap_options", "theme", "thread", + "thread_event_store_does_not_replay_server_resolved_request", + "thread_event_store_drops_requests_when_turn_completes_or_thread_closes", + "thread_event_store_preserves_requests_while_evicting_old_notifications", + "thread_event_store_request_capacity_fails_closed_without_evicting_requests", + "thread_event_store_tracks_delta_bytes_after_coalescing", "thread_line_with_preview", + "thread_notifications_route_to_their_thread", "thread_pages_append_unique_threads_and_keep_the_next_cursor", "thread_state", "thread_to_transcript_entries", "tmux_and_local_failures_fall_back_to_osc52", "tmux_requires_clipboard_forwarding_capability", + "to_mode", + "to_mode_preserves_an_explicit_effort_clear", + "to_mode_uses_mask_values_and_fallbacks", + "to_tui_event", "toggle_filter", + "toggle_grouping", "toggle_sort", "too_many_lines_fall_back_without_highlighting", + "tool_call_history_cells_cover_collaboration_lifecycle", "trailing_newline_does_not_create_a_phantom_line", "transcript", "transcript_load_event_records_loaded_and_failed_states", @@ -77546,6 +79282,7 @@ "transcript_page_size", "transcript_page_size_tracks_resize", "transcript_pager_footer", + "transcript_pager_is_open", "transcript_pager_renders_canonical_entries_scrolls_and_closes_with_ctrl_t", "transcript_preview_text", "transcript_scroll_offset", @@ -77556,6 +79293,7 @@ "truncate_display", "truncation_preserves_display_width", "tui_client_uses_stable_identity_and_v2_capabilities", + "turn_completion_clears_all_turn_indexed_prompts", "turn_completion_clears_the_active_status_timer", "turn_completion_inserts_missing_canonical_items_before_known_following_items", "turn_completion_repairs_missing_streamed_items_from_canonical_turn", @@ -77563,7 +79301,6 @@ "turn_label", "turn_status", "turn_terminal_status_settles_in_progress_items", - "unarchive_thread", "unicode_cursor_and_grapheme_deletion_match_codex_textarea_semantics", "unicode_labels_remain_single_stable_row", "unified_diff_uses_stable_line_numbers_and_gutter_signs", @@ -77571,22 +79308,32 @@ "unknown_fenced_language_preserves_plain_code", "unknown_language_falls_back_to_plain_text", "unselected_dim_rows_keep_numbering_and_dim_style", + "unsupported_requests_return_for_rejection", "untitled_conversation", + "update_collaboration_mode", + "update_cursor_anchor", + "update_inline", + "update_rows", "update_settings", "updated_label", "upsert_queued_submission", "url_detection_rejects_file_paths", + "user_input_request", + "user_input_requests_are_removed_in_fifo_order_per_turn", "user_input_text", "user_item", "user_visible_header_labels_cover_all_product_locales", "validate_model_route", + "viewport_area", "visible_buffer_lines", "visible_models", + "visible_rows", "visible_terminal_text", "visible_terminal_text_ignores_cursor_controls", "wait_for_ledger_kind_and_scenario", "wait_for_marker", "wait_for_marker_after", + "wait_for_screen_marker", "width_allocation_preserves_compact_columns_after_token_heavy_columns", "windows_path_to_wsl", "with_status_suffix", diff --git a/internal/roadmap/coding/README.md b/internal/roadmap/coding/README.md index 0dd384dd9..a53c568a6 100644 --- a/internal/roadmap/coding/README.md +++ b/internal/roadmap/coding/README.md @@ -139,7 +139,7 @@ User coding intent | sequence gate | 已扩展到 patch / command / test / action lifecycle,并能把 `action.resolved` 从历史 `action.required` 回填到对应 tool;终态 turn 后继续追加 execution stream 会 fail closed。 | 后续只随新增 lifecycle fact 扩展,不再为旧 thread item 兼容新增旁路。 | | projection | `CodingWorkbenchView` selector 已合并 RuntimeEvent 与 `thread_read.commands/tests/pending_requests/changeSummary/model_routing`,输出 active command/test/action、变更摘要、diagnostics 与 routing facts;session overview、output、log、action submit 和 diagnostics 均从同一 projection 派生。 | 继续守住旧 thread item 不回流;新增 UI fact 先进入 RuntimeEvent / read model / projection,再进入组件。 | | Workspace UI | Coding mode 继续作为 current Workbench surface;变更、输出、日志、审批、诊断、失败继续、provider 设置深链、执行策略恢复和 session overview 均已消费 `CodingWorkbenchView` 或 current reliability/routing evidence;`smoke:code-artifact-workbench-electron-fixture -- --scenario gui-coding-input` 已证明 GUI 输入到 changes / outputs / logs / recovery metadata 的 current 闭环。 | 新增可见状态继续保持 projection-only;新增 coding fact 必须先进入 RuntimeEvent / read model / projection。 | -| ExecutionBackend | P2 已接入 RuntimeBackend tool event mirror、真实 `patch-apply` workdir apply service、command/test/action facts、output refs、checkpoint refs、artifact/evidence refs、policy/sandbox/action metadata、tool confirmation resume、默认 policy rule catalog、组织 / 用户 / runtime / request policy layer、network rules 与 macOS/Linux sandbox runner。Windows `windowsSandbox/readiness`、setup mode、elevated sandbox-account、unelevated current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 和 warning lifecycle 已接入同一 current owner;Windows runner schema v3 `7/7` 与复验 run 是平台 evidence。P2-B first-core-slice 已新增 `agent_tools::execution::process` owner,具备 process snapshot、stdout/stderr delta、有界 retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已新增 `executionProcess/start`、`executionProcess/writeStdin`、`executionProcess/interrupt`、`executionProcess/terminate`、`executionProcess/status`、`executionProcess/drainOutput` JSON-RPC 控制面,client / schema / processor 契约已接通。P2-B second slice 已让 no-sandbox shell path 使用 live process 输出 `tool.output.delta`,并在启动前复用 Lime `ToolExecutionDecision` 与 Agent `ToolRegistry::check_tool_permissions`;live process 启动成功后会立即发 metadata-only delta,把 `processId / running / live_process` hydrate 到 `thread_read.commands`,终态前也会发 metadata-only delta,让 Workbench/read model 不必只等 `ToolEnd.metadata` 才知道进程结束;`executionProcess/start` 也已收敛为受控入口:只接受 shell canonical tool,必须携带 `workingDirectory`,先过 Lime decision 与 Agent safety / permission preflight,workspace sandbox backend required / enforced 时 fail-closed,不再用 `cwd` 覆盖实际工作目录。需要 workspace sandbox backend 的命令仍留在 Agent sandbox executor,不走裸进程。`agent_tools::tool_orchestrator` 已新增 `LiveExecutionProcessRegistry` seam,App Server `ExecutionProcessServer` 已实现该 registry,可承接 agent live process control handle、bounded output replay 与 final snapshot 到现有 `executionProcess/*` owner;主对话 shell 终态已把 embedded / sandbox / live process metadata 投影为 `processId / executionProcessStatus / executionSurface / outputBytes / outputOmittedBytes / outputTruncated / stdoutBytes / stderrBytes`,并 hydrate 到 `thread_read.commands` 与 `CodingWorkbenchView.commands`。RuntimeBackend 主对话 Bash / PowerShell foreground no-sandbox 路径已通过 Agent native tool hook 复用同一个 App Server `ExecutionProcessServer`;Workbench 输出面板已通过 `src/lib/api/executionProcess.ts` 复用 App Server current client,live process 可触发 interrupt / terminate / status / drain 控制;RuntimeBackend lifecycle 已把 `executionProcessControlStatus=registered` 与 `stdinWritable` 透传到 `thread_read.commands` / `CodingWorkbenchView.commands`,控制操作成功后会刷新 current session read model;Workbench 已在 `stdinWritable=true` 时显示 stdin 输入,提交后复用 `executionProcess/writeStdin` current API 并刷新 current session read model;组件只消费 injected controls,不直接散落 JSON-RPC 调用。RuntimeBackend 已新增 `RuntimeBackendProcessControlCapabilities` host 合同,默认 `none` 不声明共享 registry,显式 factory 入口才把 shared `ExecutionProcessServer` 的 status / drain / interrupt / terminate / stdin 能力透传给 host;当前仓库没有生产 `RuntimeBackendHost` 实现,只有 feature-gated 测试 host。 | 外部 Agent host 消费该合同只在真实 host adapter 出现时继续;当前下一刀回到可验证的 P2-B 默认 command/test sandbox-aware process owner,或先在 `tool-runtime` 实现 P2-C Windows restricted-token runner。不得在 adapter 内伪造本地 registry,也不得绕过 policy / sandbox。 | +| ExecutionBackend | P2 已把 canonical command/test 收敛到 unified exec + `RuntimeToolExecutionAttempt` + App Server `ExecutionProcessServer`,共享 policy/sandbox、process snapshot、stdin/control、terminal lifecycle 与 Thread/Turn/Item projection。P2-A 的 `BoundedProcessOutput` 已覆盖本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 和远端 Environment:默认保留 1 MiB head/latest tail、精确记录 omitted bytes、插入 omission marker;64-slot broadcast、UTF-8 scalar framing、unified exec bounded observation 与逐进程 replay 均由 current owner 承接。Windows runner schema v3 `7/7` 是历史平台 evidence,当前 SHA 的 Windows 大输出仍待真机复验。 | P2-A/P2-B current owner 已闭环;下一刀直接进入 Pier/DeepSWE verifier 与正式 distribution evidence。外部 Agent host 只在真实 adapter 出现时消费既有 capability,不得伪造 registry 或绕过 policy/sandbox。 | | 多模型 slot | P5 已进入 App Server current routing facts:`base/coding/review/fast/local` slot 解析、Provider Store readiness、Model Registry metadata、`thread_read.model_routing`、Reliability routing card 与五语言诊断文案已接通;未配置 provider 会结构化 `needs_setup / blocked`,不降级 mock。 | 具体 provider/key 编辑深链和独立 `model_slot.resolved` 事件仅在 evidence join 需要时继续补强。 | | P8 守卫 | 骨架阶段已完成防回流收口:旧 thread item adapter 已从 Workbench current surface 删除;`code_orchestrated / auto -> react` 归一收进 TS / Rust 单一 compat helper;legacy `agent_runtime_*` 只允许 retired guard / contract negative test / test-only fixture / 明确 compat 文档;`mockPriorityCommands` 为空,生产 `safeInvoke` 不回退 mock。 | 后续只随新增 coding fact / command / GUI 主路径补守卫,不再把 guard/test-only 字符串当主线 blocker。 | @@ -153,59 +153,52 @@ User coding intent | 整体目标完成度 | P0-P8 全量 coding profile 的完成比例,不因文档完成而大幅提高。 | | 可交付状态 | 是否达到 Lime GUI 产品可用闭环:真实 current 主链 + GUI smoke + evidence。 | -当前整体目标完成度按工程闭环估算约 `99.1%`。Windows sandbox setup/readiness、双 mode restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 与 warning lifecycle 已有 current owner 和 Windows schema v3 `7/7` evidence;剩余主要缺口是默认 command/test sandbox-aware process runner 完整替代 batch outcome bridge、Pier/DeepSWE verifier artifacts 与覆盖当前 SHA 的正式分发证据。P2-A 当前只证明 `tool-runtime::execution_process` 的 live process retained output 有界,通用 sandbox output buffer 仍需在各 backend 执行边界补齐;P2-B 已新增 execution process owner、本地 process runner、App Server current control API、受控 `executionProcess/start`、no-sandbox shell live process bridge、终态 lifecycle delta、live process registry seam 与 RuntimeBackend 共享 `ExecutionProcessServer` hook,让主对话 Bash/PowerShell foreground no-sandbox 执行和 Workbench `executionProcess/*` 控制面指向同一个 process owner;Workbench 输出面板已能对 live process 调用 current `executionProcess/*` 控制面,状态 / 输出控制成功后会刷新 current read model,并能从 `CodingWorkbenchView.commands` 恢复 `executionProcessControlStatus` 与 `stdinWritable`;stdin 输入入口已复用 `executionProcess/writeStdin` current API 并在写入成功后刷新 read model;agent batch/live shell lifecycle metadata 已补齐启动、输出与终态的 `stdinWritable / stdin_writable`,注册到 shared registry 后启动与终态 delta 也会携带 `executionProcessControlStatus`;RuntimeBackend host 合同已能显式声明共享 process control capability。不能宣称全量完成的主因仍是两条:默认 command/test sandbox-aware process runner/control owner 尚未完全替代 batch outcome bridge,以及 Pier/DeepSWE verifier artifacts 与覆盖当前 SHA 的正式分发证据尚未完成。外部 Agent host 当前没有生产 adapter,不再作为可立即执行的 blocker。 +当前整体目标完成度按工程闭环估算约 `99.4%`。P2-A/P2-B/P2-C 已完成 current owner 收敛:canonical command/test 共享 sandbox-aware process/control owner;所有本地 sandbox/process backend 与远端 Environment 共享 1 MiB head/tail、精确 omission、bounded broadcast/replay、UTF-8 framing 和有界 observation;Windows sandbox setup/readiness、双 mode restricted-token runner、ACL/Job/ConPTY、Firewall/WFP 与 warning lifecycle 也已有 current owner。剩余主要缺口是 Pier/DeepSWE verifier artifacts、覆盖当前 SHA 的正式 distribution evidence,以及 Windows 当前 SHA 的 restricted-token 大输出真机复验。外部 Agent host 当前没有生产 adapter,不再作为可立即执行的 blocker。 ## 下一刀 -下一刀继续沿主链补强可执行能力,而不是重复 P1-P7 baseline: +下一刀进入正式 verifier/distribution evidence,不再重复 P1-P8 current owner 实现: ### 下次从这里开始 -主任务:`P2-B live process lifecycle fifth slice`。RuntimeBackend 主对话 Bash/PowerShell foreground no-sandbox 路径已通过 Agent native tool hook 复用 App Server `ExecutionProcessServer`,让 Workbench 已接入的 interrupt / terminate / status / drain / stdin 写入控制面可指向同一个 `executionProcess/*` current owner;status / drain / control / stdin 操作成功后已触发 current read model refresh,`executionProcessControlStatus` 与 `stdinWritable` 已进入 RuntimeCore lifecycle hydrate;agent batch/live shell lifecycle delta 也已在启动、输出和终态同步携带 stdin/control capability。RuntimeBackend host 合同已定义默认 none 与显式 shared control capability;仓库当前没有生产 `RuntimeBackendHost` adapter 可继续接入。不新增旧命令、不绕过 `ToolExecutionDecision`。剩余是默认 command/test sandbox-aware process runner/control owner 和 Windows restricted token 完整化。 +主任务:为当前工作树生成 Pier/DeepSWE verifier artifacts 与正式 distribution evidence。P2-A/P2-B 已闭环;不得再以 output buffer、live process adapter、旧 UI 或文档 inventory 替代 verifier 工作。Windows 当前 SHA 的 restricted-token 大输出必须由 Windows 真机 runner 补证,非 Windows 结果不能提升为平台通过。 入口文件: -- `lime-rs/crates/app-server/src/runtime_factory.rs`、`lime-rs/crates/app-server/src/runtime_backend.rs`、`lime-rs/crates/app-server/src/runtime_backend/live_execution_process.rs`:RuntimeBackend 共享 registry 注入点。 -- `lime-rs/crates/app-server/src/runtime_backend.rs`:外部 host adapter;host 合同已携带 `process_control` capability,后续只能让外部 host 显式消费该合同并接入真实 owner,不能在 adapter 内伪造 owner。 -- `lime-rs/crates/agent/src/agent_tools/tool_orchestrator.rs`:当前 shell live process bridge 与 batch outcome 接线。 -- `lime-rs/crates/agent/src/agent_tools/execution/process.rs`:process snapshot、stdout/stderr delta、stdin、interrupt、terminate、status owner。 -- `lime-rs/crates/app-server/src/execution_process.rs`:App Server current process control owner。 -- `lime-rs/crates/app-server/src/runtime_backend/coding_events.rs` 与 `lime-rs/crates/app-server/src/runtime/coding_activity_projection.rs`:process facts 到 read model hydrate。 -- `src/lib/api/executionProcess.ts` 与 `src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.tsx`:前端 current 控制面。 +- `scripts/harness/`:DeepSWE adapter、preflight、Desktop controlled/live verifier 与聚合入口。 +- `internal/exec-plans/evidence/` 与 `.lime/benchmark/v2/`:当前 SHA 的持久 evidence、运行期机器可读结果与 claim boundary。 +- `packages/cli/`、`forge.config.mjs` 与 release resource verifier:正式 CLI/Desktop distribution 产物边界。 +- `.github/workflows/`:Windows restricted-token 大输出与 packaged matrix 的平台执行入口。 退出条件: -- command / test 默认执行不再只依赖 terminal `ToolEnd` + batch metadata,启动后能稳定产生 process lifecycle facts。 -- RuntimeBackend 主对话 live process 注册到 App Server `ExecutionProcessServer` 后,`executionProcess/status|drainOutput|interrupt|terminate` 能控制同一进程。 -- stdin capability / status hydrate 进入 RuntimeCore lifecycle,Workbench 控制状态可从 `CodingWorkbenchView.commands/tests` 恢复;`stdinWritable=true` 时 Workbench 通过 `executionProcess/writeStdin` 写入 stdin 并刷新 read model;RuntimeBackend host 只能在看到 shared process control capability 后消费共享 owner。 -- 需要 workspace sandbox backend 的命令仍走 Agent sandbox executor;no-sandbox live process 与 App Server `executionProcess/start` 都必须继续消费 `ToolExecutionDecision.requires_sandboxed_execution()`。 +- verifier 输入绑定当前 commit/tree identity,产物可机械复验,不能引用已删除 source 或历史 SHA 冒充当前结果。 +- DeepSWE/Pier 的 controlled、live、score 与 platform claim 分层记录;无 live provider 或容器环境时保持明确 blocked/incomplete。 +- CLI/Desktop distribution 从真实打包产物启动,证明 sibling sidecar、App Server JSON-RPC、RuntimeCore 与 canonical projection,不以源码运行替代。 +- Windows restricted-token 大输出复验覆盖 1 MiB head/tail、omitted bytes、UTF-8 delta 和 terminal completion;失败时 readiness/distribution claim fail closed。 最小验证: ```bash -cargo fmt --manifest-path "lime-rs/Cargo.toml" -p lime-agent -p app-server --check -cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::execution -- --nocapture -cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::tool_orchestrator -- --nocapture -cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server execution_process -- --nocapture -cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend::coding_events -- --nocapture -npx vitest run "src/lib/api/executionProcess.test.ts" "src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.test.tsx" --silent=passed-only --disableConsoleIntercept +npm run harness:deepswe:preflight +npm run smoke:cli-npm-gate-b +npm run test:contracts +npm run smoke:agent-runtime-current-fixture +npm run governance:legacy-report ``` ```text -P2-B live process lifecycle fifth slice - -> RuntimeBackend shared ExecutionProcessServer hook landed for foreground no-sandbox Bash/PowerShell - -> stdin input UX wired to executionProcess/writeStdin after status hydrate - -> RuntimeBackend host contract now exposes shared process control capability - -> agent batch/live shell lifecycle deltas carry stdin/control capability from process start to terminal state - -> external Agent host consumption waits for a real production host adapter - -> next: default command/test sandbox-aware process owner or P2-C Windows sandbox completeness +P2-A/P2-B current owner closed + -> next: current-tree Pier/DeepSWE verifier artifacts + -> next: CLI/Desktop distribution evidence + -> next: Windows current-SHA restricted-token large-output evidence ``` P8 骨架阶段已经收口,不再重复做旧字符串 inventory:真实 turn 已能从 Agent tool stream 派生 `file.read`、`file.changed`、`patch.*`、`command.*`、`test.*`、`permission.denied`、`sandbox.blocked` 和 `action.*` 基础 facts,并通过 `CodingWorkbenchView`、GUI coding input smoke、Evidence Pack、`legacySurfaceCatalog`、`test:contracts` 与 `governance:legacy-report` 证明 current 主链和防回流守卫成立。 后续高价值顺序: -1. 继续 P2-B:Workbench live process interrupt / terminate / status / drain / stdin 写入已接入 current API,agent live process registry seam、App Server owner、RuntimeBackend 共享 hook、status/control/stdin capability hydrate 与 RuntimeBackend host control capability 合同已落地;仓库当前没有生产外部 Agent host adapter,下一刀回到默认 command/test sandbox-aware process runner/control owner;no-sandbox shell live process 已落地,不再重复做裸进程接管。 +1. P2-B 已收口:Workbench live process interrupt / terminate / status / drain / stdin 写入、agent live process registry seam、App Server owner、RuntimeBackend 共享 hook、status/control/stdin capability hydrate、unified exec canonical lifecycle 与 sandbox `RuntimeToolExecutionAttempt` shared owner 回归均已落地;仓库当前没有生产外部 Agent host adapter,不再重复做裸进程接管。 2. 再补 P2-D:为 shell command 建 session-scope approval key,减少重复审批,但只通过 settings 草案进入持久规则。 -3. 继续把默认 command/test 执行切到统一 process/control owner,并补当前工作树的 Pier/DeepSWE verifier 与正式分发证据。Windows restricted-token runner、setup/readiness 和平台 schema v3 `7/7` 已完成 current owner 收敛;任何后续 token/ACL/network 改动都必须重新跑 Windows matrix,失败时 readiness 回退 `updateRequired`。 +3. 为当前工作树补 Pier/DeepSWE verifier 与正式分发证据;Windows restricted-token runner、setup/readiness 和平台 schema v3 `7/7` 已完成 owner 收敛,但当前 SHA 仍需重跑大输出 matrix,失败时 readiness 回退 `updateRequired`。 4. 若需要外部 CLI / harness 接入,按 P6 只输出 RuntimeEvent / ReadModel adapter,不允许成为生产必需主链。 diff --git a/internal/roadmap/coding/implementation-plan.md b/internal/roadmap/coding/implementation-plan.md index ef3325a14..2ec30480d 100644 --- a/internal/roadmap/coding/implementation-plan.md +++ b/internal/roadmap/coding/implementation-plan.md @@ -167,7 +167,7 @@ turn start - 命令、测试和 patch 的失败分类可被 projection 直接消费。 - Windows / macOS / Linux 平台差异进入 policy/sandbox diagnostics,不隐藏为普通失败。 -当前状态:`in_progress / current coding skeleton usable / P2-A live-process retained output bounded / P2-B no-sandbox live process landed / P2-C Windows runner enforced with platform evidence`。P2 主干已接入 runtime tool event mirror、真实 `patch-apply` apply service、per-file checkpoint/content/diff refs、policy/sandbox/action facts、output snapshot、file checkpoint snapshot、history hydrate 与 active command/test/action read model;artifact/checkpoint refs 继续由 RuntimeCore read-model 边界承接。`tool-runtime::sandbox` 已承接 sandbox label、命令文本抽取、只读 shell 分类和 workspace sandbox backend plan;Agent 前台 `Bash/PowerShell` 已能消费 workspace sandbox context,macOS seatbelt 与 Linux bubblewrap 在可用时进入 `ready/enforced=true` current runner。Windows 已接入 `elevated/unelevated` setup mode、sandbox-account/current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、readiness read-back 和 warning lifecycle;Windows runner schema v3 `7/7` 与复验 run 是平台 evidence,后续改动必须保持 fail-closed。P2-A 当前只证明 `tool-runtime::execution_process` 的 live process retained output 有界;通用 sandbox output buffer 仍需在各 backend 执行边界补齐,RuntimeCore 继续作为 output snapshot owner。P2-B 已新增 `tool-runtime::execution_process` owner、App Server `executionProcess/*` 控制面与 no-sandbox live process bridge;剩余高价值骨干是把 command/test 默认执行从 batch outcome bridge 切到 sandbox-aware process runner/control owner,并复用 `executionProcess/*` current API。 +当前状态:`completed for P2 current owner / P2-A bounded output boundary / P2-B sandbox-aware canonical command-test owner / P2-C Windows runner owner`。P2 主干已接入 runtime tool event mirror、真实 `patch-apply` apply service、per-file checkpoint/content/diff refs、policy/sandbox/action facts、output snapshot、history hydrate 与 active command/test/action read model;artifact/checkpoint refs 继续由 RuntimeCore read-model 边界承接。Agent 前台 `exec_command` 已通过 `RuntimeToolExecutionAttempt` 进入 unified exec 与 App Server shared process owner,macOS seatbelt 与 Linux bubblewrap 在可用时进入 current runner;Windows 已接入双 setup mode、restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、readiness 和 warning lifecycle。P2-A 已让 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 共享 1 MiB head/tail、精确 omission、64-slot broadcast、UTF-8 framing、unified exec bounded observation 与逐进程 replay。下一步直接进入当前工作树的 verifier/distribution evidence;Windows schema v3 `7/7` 只作历史平台基线,当前 SHA 的大输出仍需 Windows 真机复验。 2026-06-14 增量: @@ -227,7 +227,19 @@ turn start - 2026-06-26 P2-B RuntimeBackend host contract 验证证据:`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend runtime_backend -- --nocapture`、`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend agent_factory_can_share_execution_process_owner_with_runtime_core -- --nocapture`、`CARGO_TARGET_DIR="/tmp/lime-p2b-agent-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server --features agent-backend runtime_backend_json_rpc_agent_flow_smoke_covers_artifact_read_and_action_response -- --nocapture` 均通过。`rustfmt --edition 2021 --config skip_children=true --check "lime-rs/crates/app-server/src/runtime_backend.rs" "lime-rs/crates/app-server/src/runtime_factory.rs" "lime-rs/crates/app-server/src/lib.rs"`、`npx prettier --check "internal/roadmap/coding/README.md" "internal/roadmap/coding/implementation-plan.md"`、`git diff --check -- ...` 已通过;全量 `rustfmt --edition 2021 --check ...` 曾因无关脏文件 `lime-rs/crates/app-server/src/runtime/plugin_worker_turn.rs` 需要格式化而失败,本轮未改该文件。 - 2026-06-26 P2-B live process lifecycle metadata consistency 增量:`agent_tools::execution::process` 现在在 output delta、start snapshot 与 terminal snapshot metadata 中统一输出 `stdinWritable / stdin_writable`;`agent_tools::tool_orchestrator` 在 no-sandbox live process 注册到 `LiveExecutionProcessRegistry` 后,会把 `executionProcessControlStatus / execution_process_control_status` 同步写入 start lifecycle delta 与 terminal lifecycle delta。这样默认 batch/live shell 路径不只在最终 `ToolEnd.metadata` 才暴露 stdin/control capability,`command.output -> thread_read.commands -> CodingWorkbenchView.commands` 可以从启动阶段恢复同一 process owner 的控制状态。 - 2026-06-26 P2-B live process lifecycle metadata consistency 验证证据:`CARGO_TARGET_DIR="/tmp/lime-p2b-process-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::execution::process -- --nocapture` 通过 6 个 process owner 测试;`CARGO_TARGET_DIR="/tmp/lime-p2b-process-target" cargo test --manifest-path "lime-rs/Cargo.toml" -p lime-agent agent_tools::tool_orchestrator -- --nocapture` 通过 14 个 orchestrator 测试,覆盖启动 lifecycle delta 的 `stdinWritable=true`、终态 lifecycle delta 的 `stdinWritable=false`、注册后 start delta 带 `executionProcessControlStatus=registered`,以及 registry output / final snapshot 记录。 -- 未完成:P2-B 下一刀回到默认 command/test sandbox-aware process runner/control owner;外部 Agent host 消费 `process_control` 合同只在真实生产 host adapter 出现时继续;Windows runner、setup/readiness、平台 evidence 已完成,network/policy 单条规则深链属于 P7/P8 增强。P3/P4 UI evidence、Provider slot diagnostics 与 GUI coding input baseline 已进入 current 骨架闭环,不再作为主线 blocker。 +- 2026-09-06 P2-B command/exec owner 收口增量:App Server `command/exec` 现在启动后立即注册到共享 `ExecutionProcessServer`,stdout/stderr delta、终态 snapshot、超时终止、stdin 写入和 connection close 都复用同一 process owner;公开 `processId` 仍按 connection scope 复用,内部 owner ID 以 `command-exec-{connectionId}-{processId}` 为 base,终态 owner 已保留时追加 instance UUID,避免跨连接冲突和终态复用冲突。重复 session 在启动前拒绝,resize 或 owner 注册失败会终止已启动进程;新增测试验证 shared status/output replay、跨连接同名 process ID 和终态后复用公开 process ID。 +- 2026-09-06 P2-B command/exec owner 验证证据:`rustfmt --edition 2021 "lime-rs/crates/app-server/src/command_exec.rs" "lime-rs/crates/app-server/src/command_exec/tests.rs"` 与 `git diff --check -- "lime-rs/crates/app-server/src/command_exec.rs" "lime-rs/crates/app-server/src/command_exec/tests.rs"` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server command_exec --lib -- --nocapture` 与 `cargo test --manifest-path "lime-rs/Cargo.toml" -p tool-runtime execution_process -- --nocapture` 均被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。 +- 2026-09-06 P2-B unified exec command/test lifecycle 增量:`tool-runtime::unified_exec` 现在把同一 `ExecutionProcessSnapshot` 的 `processId / executionProcessStatus / stdinWritable / outputBytes / outputOmittedBytes / outputTruncated / exit_code` 与 `executionSurface=unified_exec` 写入每次 canonical result;短命令、yield 后 running 命令和 `write_stdin` 续接保持同一 process identity。App Server `runtime_backend::coding_events::CodingEventMirror` 已消费 `Command` Item 的 `ItemStarted -> ItemUpdated -> ItemCompleted`,将统一 exec 产出为 `command.started/output/exited` 与 `test.started/completed`,并保留 bounded output/process metadata;不新增 JSON-RPC method,不绕过 sandbox executor。 +- 2026-09-06 P2-B unified exec command/test lifecycle 验证证据:`rustfmt --edition 2021 --check "lime-rs/crates/tool-runtime/src/unified_exec.rs" "lime-rs/crates/tool-runtime/src/unified_exec/tests.rs" "lime-rs/crates/app-server/src/runtime_backend/coding_events.rs" "lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs"` 与对应 `git diff --check` 通过;新增 unified exec snapshot metadata 与 `Command` Item 生命周期回归。`cargo test --manifest-path "lime-rs/Cargo.toml" -p tool-runtime unified_exec --lib -- --nocapture`、`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime_backend::coding_events --lib -- --nocapture` 均被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。 +- 2026-09-06 P2-B command lifecycle projection correction:`CodingEventMirror` 统一 `Command` 的 `commandId`(包括 terminal interaction),对无 `ItemStarted` 的冷恢复终态补发 started/output/exited,并将 `Cancelled`/`Interrupted`/aborted/terminated 映射为 `canceled`(含 `test.completed`)。新增 call-id、一致性、冷恢复和取消回归;未新增协议或 owner。 +- 2026-09-06 P2-B thread shell live output 收口:`thread/shellCommand` 继续复用共享 `ExecutionProcessServer`,轮询改为按 `processId + afterSequence` drain bounded output,并在同一 turn 内即时落 `command.output`;终态仍由同一 snapshot 生成 `command.exited`,启动/输出/终态统一携带 `executionProcessStatus / executionProcessControlStatus / executionSurface / stdinWritable / outputBytes` 等 owner facts。显式 `status=canceled` 优先于 exit code,避免 terminated shell 被 Thread Item 投影为 failed/completed。新增 session shell lifecycle、delta、process-control metadata 回归;未新增 JSON-RPC method、legacy owner 或裸进程路径。 +- 2026-09-06 P2-B thread shell live output 验证证据:`rustfmt --edition 2021 --check "lime-rs/crates/app-server/src/runtime/session_shell.rs" "lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs" "lime-rs/crates/app-server/src/runtime/tests/session_shell.rs"`、对应 `git diff --check`、`npm run test:contracts` 与 `npm run governance:legacy-report` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server runtime::tests::session_shell --lib -- --nocapture` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为通过。 +- 2026-09-06 P2-B unified exec live output bridge 增量:`RuntimeToolExecutorHandle::execute_call` 将 canonical `ToolCall` 的生命周期 emitter 注入 `RuntimeToolExecutionContext`,`tool-runtime::unified_exec` 在每次 `processId + afterSequence` drain 后按原序发出 `ToolOutputDeltaEvent`,并把 `outputSequence / outputKind / outputBytes / outputOmittedBytes / outputTruncated / executionProcessStatus / stdinWritable / executionSurface` 与 terminal snapshot 对齐;`write_stdin` 续接继续使用原始 exec call/turn identity,`CodingEventMirror` 对已流式输出的终态抑制聚合 output 重复,不新增 Agent 依赖、JSON-RPC method 或第二套 output owner。 +- 2026-09-06 P2-B unified exec live output bridge 验证证据:`rustfmt --edition 2021 --check` 覆盖 `tool_call.rs`、`tool_executor.rs`、`unified_exec.rs`、`unified_exec/tests.rs`、current orchestration 与 App Server unified exec fixtures,`git diff --check` 通过;unified exec fixture 新增短命令 `started -> output -> completed` metadata/call-id/sequence 回归。`cargo check/test` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为 Rust 通过。 +- 2026-09-06 P2-B sandbox attempt control-owner 回归:`app-server/src/execution_process/tests.rs` 新增带 `RuntimeToolExecutionAttempt` 的 `workspace-write` 交互命令 fixture,证明 sandbox attempt 通过 `ExecutionProcessServer` 同一 current owner 完成启动 snapshot、按 process identity drain output、stdin 写入、终态 status,并核对 `tool_id` 与 attempt call identity;结合 unified exec/current orchestration wiring,P2-B canonical command/test owner 迁移证据闭环。`rustfmt --edition 2021 --check` 与 `git diff --check` 通过;`cargo test --manifest-path "lime-rs/Cargo.toml" -p app-server execution_process --lib` 仍被本机 `rusty_v8 v150.4.0` Darwin arm64 archive HTTP 404 阻断,未计为 Rust 通过。 +- 2026-09-06 P2-A bounded output boundary 收口:新增 `tool-runtime::execution_process::BoundedProcessOutput` 与 `ProcessOutputFramers` current owner。默认保留 1 MiB head/latest tail、精确统计 omitted bytes 并插入 omission marker;pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 统一从 raw bytes 捕获,UTF-8 scalar 可跨 reader chunk,EOF flush 不完整后缀。reader -> handle 改为 64-slot bounded broadcast;unified exec observation 不再无界增长;App Server replay 下沉到逐进程 queue,远端输出删除手写 tail-only 截断。 +- 2026-09-06 P2-A 验证证据:`npm run test:rust:unit -- -p tool-runtime execution_process` 28/28,`npm run test:rust:unit -- -p tool-runtime unified_exec` 11/11,`npm run test:rust:unit -- -p app-server execution_process` 20/20;覆盖 1 MiB head/tail、精确 omission marker、未消费 delta 的大输出 terminal completion、远端 snapshot 一致性、逐进程 replay 隔离与跨 chunk UTF-8。Windows 当前 SHA 的 restricted-token 真机大输出尚未复跑,不提升为平台 evidence。 +- 未完成:当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence,以及 Windows 当前 SHA 的 restricted-token 大输出真机复验。外部 Agent host 消费 `process_control` 合同只在真实生产 adapter 出现时继续;network/policy 单条规则深链属于 P7/P8 增强。P2-A/P2-B/P2-C current owner、P3/P4 UI evidence、Provider slot diagnostics 与 GUI coding input baseline 均不再作为主线 blocker。 ## P3:AgentUI Coding Projection @@ -326,7 +338,7 @@ turn start - 2026-06-15 Windows restricted token hardening 增量:restricted token runner 已补 Job Object `KILL_ON_JOB_CLOSE`、timeout 时 `TerminateJobObject` 杀进程树、ACL rollback RAII 恢复 workspace / denied path DACL,以及 stdout/stderr 并发 pipe reader,避免大输出填满 pipe 后父进程仍阻塞在 `WaitForSingleObject`。 - 2026-06-15 P2-A 输出流控增量:`sandbox/output_buffer.rs` 已成为 Agent 有界输出捕获 owner;通用 sandbox executor 从 `Command::output()` 改为 spawn + 并发读取 stdout/stderr 到 head/tail buffer,Windows restricted token pipe reader 改为同一 buffer,embedded Bash 前台执行也改为同一有界捕获路径;`BashTool` metadata 继续输出 stdout/stderr 原始字节数、省略字节数、截断标记,以及 `outputBytes / outputOmittedBytes / outputTruncated`,交给 App Server output refs / evidence refs 消费。 - 2026-06-15 P2-A 验证证据:`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib sandbox::output_buffer -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib sandbox::executor::tests::unsandboxed -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib tools::bash::tests::test_execute_large_output_reports_bounded_capture_metadata -- --nocapture`、`cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib tools::bash::tests::test_executor_result_metadata_preserves_output_truncation_stats -- --nocapture`。上述测试通过时仅有既有 `worktree_tools.rs` unused `SessionManager` warning。 -- 2026-06-15 P2-B Workbench process control 增量:`src/lib/api/appServer.ts` 已补齐 App Server current `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput` client 方法,`src/lib/api/executionProcess.ts` 作为唯一前端领域网关返回 `response.result`,不走 `safeInvoke` 旧命令或 mock fallback;`CodingWorkbenchOutputPanel` 只对 `processId + running/starting` 的 `CodingWorkbenchView.commands` 渲染 icon controls,并由 `workspaceConversationCodingViews.tsx` 注入 interrupt / terminate / status / drain handlers。该增量只完成 UI 控制面接入,不代表 command/test 默认执行已切到 sandbox-aware process control owner;下一刀仍需迁默认执行 lifecycle、stdin 与 status hydrate。 +- 2026-06-15 P2-B Workbench process control 增量:`src/lib/api/appServer.ts` 已补齐 App Server current `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput` client 方法,`src/lib/api/executionProcess.ts` 作为唯一前端领域网关返回 `response.result`,不走 `safeInvoke` 旧命令或 mock fallback;`CodingWorkbenchOutputPanel` 只对 `processId + running/starting` 的 `CodingWorkbenchView.commands` 渲染 icon controls,并由 `workspaceConversationCodingViews.tsx` 注入 interrupt / terminate / status / drain handlers。该增量与后续 unified exec/current orchestration 接线共同完成 command/test 默认 lifecycle、stdin 与 status hydrate 的 current owner 收敛。 - 2026-06-15 P2-B Workbench process control 验证证据:`npx vitest run "src/lib/api/executionProcess.test.ts" "src/components/agent/chat/workspace/CodingWorkbenchOutputPanel.test.tsx" "src/lib/api/appServer.test.ts" --silent=passed-only --disableConsoleIntercept` 覆盖前端网关不调用 `safeInvoke`、App Server JSON-RPC method 名、live process 控件渲染与 processId 传递。 - 2026-06-15 Windows restricted token smoke gate 增量:`restricted_token.rs` 已新增 Windows-only smoke,直接走 `execute_in_sandbox_with_options` current owner,覆盖 workspace write 允许、外部路径写入拒绝、denied path write deny、ACL DACL restore、large output drain、timeout exit code `124` 和 Job Object 杀子进程树。Windows 实机验证入口固定为 `cargo test --manifest-path "lime-rs/crates/agent-rust/crates/agent/Cargo.toml" --target-dir "lime-rs/target" --lib restricted_token -- --nocapture`。 @@ -520,6 +532,6 @@ P8 骨架阶段剩余口径:旧 thread item 在 Coding Workbench current UI ## 当前实施入口 -P1/P2/P3/P4/P5/P7/P8 骨架已经进入 App Server / RuntimeCore current crates 与 Workbench current projection:真实 runtime backend tool stream 会派生 `file.read/file.changed/patch/command/test/permission/sandbox/action` 基础 coding events,并经 schema / sequence / tool lifecycle guard 入库;`patch-apply` 已具备真实 workdir apply service,并已接入 agent `apply_patch` current tool 入口;tool output、file checkpoint previous content、历史 current timeline hydrate、active command/test/action read model、artifact refs、policy/sandbox/network 基础 diagnostics、network rule recovery draft、provider/model routing diagnostics、sandbox 基础 owner、process lifecycle owner、App Server `executionProcess/*` 控制 API、no-sandbox shell live process、live process registry seam、RuntimeBackend 共享 `ExecutionProcessServer` hook、Workbench stdin 写入入口、RuntimeBackend host process control 合同、inventory 配置来源测试、GUI coding input smoke、session overview projection-only 守卫、production mock fallback fail-closed 与 legacy command 防回流契约都已具备 current facts。后续实施不要回到纯文档、旧 UI 修补或旧命令 inventory;下一刀回到默认 command/test sandbox-aware process runner/control owner,外部 Agent host 消费合同等真实生产 adapter 出现后再做。 +P1/P2/P3/P4/P5/P7/P8 骨架已经进入 App Server / RuntimeCore current crates 与 Workbench current projection:真实 runtime backend tool stream 会派生 `file.read/file.changed/patch/command/test/permission/sandbox/action` 基础 coding events,并经 schema / sequence / tool lifecycle guard 入库;`patch-apply`、output/checkpoint refs、历史 hydrate、active read model、policy/sandbox/network diagnostics、provider/model routing、sandbox/process lifecycle owner、App Server `executionProcess/*`、unified exec canonical command/test、Workbench controls、GUI coding input smoke、production mock fallback fail-closed 与 legacy command 防回流契约都已具备 current facts。P2-A 的跨 backend bounded output 也已完成。后续实施不要回到纯文档、旧 UI、旧命令 inventory 或重复 output owner;下一刀直接进入正式 verifier/distribution evidence,外部 Agent host 消费合同等真实 production adapter 出现后再做。 -下次恢复入口固定为 P2-B live process lifecycle fifth slice:先读 `internal/roadmap/coding/README.md` 的“下次从这里开始”,再从 `lime-rs/crates/agent/src/agent_tools/tool_orchestrator.rs`、`lime-rs/crates/agent/src/agent_tools/execution/process.rs`、`lime-rs/crates/app-server/src/runtime_backend/live_execution_process.rs`、`lime-rs/crates/app-server/src/runtime_factory.rs`、`lime-rs/crates/app-server/src/runtime.rs` 和 `lime-rs/crates/app-server/src/execution_process.rs` 开始。第一目标不是新增 UI 或文档,也不是伪造外部 Agent host,而是补默认 command/test sandbox-aware process runner/control owner;`RuntimeBackendSubmitRequest.process_control` 合同已就绪,只有真实生产 host adapter 出现时才继续消费。同时继续通过 `ToolExecutionDecision.requires_sandboxed_execution()` 防止 sandbox required 命令走裸进程。 +下次恢复入口固定为当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence:先读 `internal/roadmap/coding/README.md` 的“下次从这里开始”,再核对 `scripts/harness/`、`packages/cli/`、Forge resource verifier、`.github/workflows/` 与 `internal/exec-plans/evidence/`。P2-A/P2-B/P2-C current owner 已完成;第一目标不是新增 UI、文档、output owner 或伪造外部 Agent host,而是生成绑定当前 tree identity 的机器可读证据,并在 Windows runner 复验 restricted-token 大输出。 diff --git a/internal/roadmap/coding/runtime-capability-map.md b/internal/roadmap/coding/runtime-capability-map.md index 9e9419c3a..0da0ea70f 100644 --- a/internal/roadmap/coding/runtime-capability-map.md +++ b/internal/roadmap/coding/runtime-capability-map.md @@ -71,10 +71,10 @@ | File read/write abstraction | RuntimeBackend mirror + ExecutionBackend file tool + artifact/checkpoint projection owner | copy | 第一刀已从 `Read/Write/Edit` 派生 `file.read/file.changed`;`file.changed` 已进入 RuntimeCore artifact/read-model 聚合与 file checkpoint API read/list/get/diff/restore 基础链路;previous content 已落到文件系统 checkpoint snapshot owner,restore 从 snapshot ref 回读后写回 live 文件;current timeline hydrate 已可从 persisted items 恢复 checkpoint snapshot refs。后续补 GUI evidence。 | | Patch apply engine | `patch-apply` crate + agent `apply_patch` tool + RuntimeBackend mirror + file artifact / checkpoint owner | copy | 已复制 patch grammar、lenient heredoc parser、streaming parser、fuzzy seek sequence 和真实 apply service 到中性 crate;agent `apply_patch` tool 已接到该 current owner,RuntimeBackend mirror 已用 parser 提取 patch paths 和 `invalid_patch` 分类,并从 tool metadata 投影多文件 `file.changed`。per-file `checkpointRef/contentRef/diffRef` 与结构化 diff 预览已进入 RuntimeCore artifact / file checkpoint / read-model 主链;后续补 GUI evidence,失败必须结构化,不只写日志。 | | Diff summary | App Server `thread_read.change_summary` + AgentUI projection `ChangeSummaryView / PatchView / FileChangeView` | rewrite | 已从 `file.changed` 与 `patch.*` 派生 read model 变更摘要,并投影到 Workbench session overview;完整 per-turn diff 仍按后续 evidence / checkpoint 细化。 | -| Shell command execution | RuntimeBackend tool mirror + Project Shell / ExecutionBackend command tool | copy | 已完成 Agent `Bash/PowerShell` tool lifecycle 到 `command.*` / `test.*` facts 的第一刀;大输出已可进入文件系统 output snapshot owner,hydrate 可恢复 output snapshot refs;`thread_read.commands/tests/pending_requests` 已进入 `CodingWorkbenchView` selector 与 Workspace adapter;policy/sandbox blocked 事件已有基础 diagnostics,真实工具拒绝 outcome 已携带 `eventClass/failureCategory/reasonCode/reason/command/cwd/platform` 等 metadata;`agent_tools::tool_orchestrator` 已在真实工具执行前接入 `ToolExecutionDecision`,shell 风险在 `on_request / unless_trusted / granular` 下会先输出 `action.required`,`read-only` sandbox 下非只读 shell 命令会先输出 `sandbox.blocked`,`workspaceSandbox.strict=true` 且 backend 未 enforce 时会先输出 strict sandbox blocked,`never` 保持非人工确认路径;Agent 主工具 inspector 已复用该 decision;App Server current 配置中的 `agent.tool_execution` 和非默认 `agent.workspace_sandbox` 已能影响 execution / inventory / decision 的 effective policy;基础 sandbox 判定与 backend plan 已收敛到 `execution/sandbox.rs`,policy resolver 已收敛到 `execution/service.rs`;macOS/Linux 前台 shell 已接进 sandbox runner,Windows setup/readiness、mode persistence、elevated sandbox-account 与 unelevated current-user restricted-token runner 已接入同一 current owner,Windows runner schema v3 `7/7` evidence 已记录。P2-B 第一刀已新增 `agent_tools::execution::process` owner 和本地 process runner,支持 stdout/stderr delta、stdin write、interrupt、terminate、status 与有界 retained output;现有 shell batch bridge 已把 process metadata 透传到 `tool.output.delta` / `command.output` metadata。剩余主缺口是 command/test 默认执行切换与当前 SHA 的正式 verifier/distribution evidence。 | +| Shell command execution | RuntimeBackend tool mirror + Project Shell / ExecutionBackend command tool | copy | canonical `exec_command` 已通过 `ToolExecutionDecision` 与 `RuntimeToolExecutionAttempt` 进入 unified exec,并由 App Server `ExecutionProcessServer` 承接 sandbox-aware process/control owner;`command.*` / `test.*` facts、output refs、active read model、approval/sandbox diagnostics、stdin/control 与 terminal lifecycle 均已接通。P2-A 同时让本地 pipe/PTY/Seatbelt/Bubblewrap/Windows restricted runner 与远端 Environment 共享 bounded output 与 UTF-8 framing。P2-A/P2-B current owner 已闭环,剩余主缺口是当前 SHA 的正式 verifier/distribution 与 Windows 平台复验证据。 | | PTY resize/write/terminate | Project Shell APIs | reference | 仅用于用户显式 shell 或工具执行面。 | | Test output parsing | ExecutionBackend test tool | rewrite | 不从自由文本猜测,需要 status/exit/output refs。 | -| Large output truncation | output ref / artifact ref + tool-runtime bounded process output | copy | 大输出进 ref,不重复刷 event;App Server runtime 已能把 tool terminal 大输出落到会话文件系统 snapshot,并在 `artifact/read(include_content=true)` 时回读完整内容;历史 hydrate 可从 `detail.outputs` 恢复 snapshot ref。`tool-runtime::execution_process::BoundedProcessOutput` 已对 live process retained output 统计 `outputBytes / outputOmittedBytes / outputTruncated`;通用 sandbox output buffer 仍需在各 backend 执行边界补齐;Windows restricted-token pipe reader 已复用 bounded process output owner。RuntimeCore 继续负责 snapshot / artifact refs,不让 executor 直接写 App Server sidecar。 | +| Large output truncation | output ref / artifact ref + tool-runtime bounded process output | copy | `BoundedProcessOutput` 默认保留 1 MiB head/latest tail,精确统计 `outputBytes / outputOmittedBytes / outputTruncated` 并插入 omission marker;所有本地 backend 与远端 Environment 已复用该 owner。reader 使用 64-slot bounded broadcast,unified exec observation 有界,App Server replay 按进程隔离;UTF-8 scalar 跨 chunk 时由 `ProcessOutputFramers` 延迟到完整边界再发 delta。RuntimeCore 继续负责 snapshot/artifact refs,不让 executor 直接写 App Server sidecar。Windows 当前 SHA 的 restricted-token 大输出仍待真机复验。 | ## 权限与安全 diff --git a/internal/roadmap/coding/upstream-gap-review.md b/internal/roadmap/coding/upstream-gap-review.md index 3f1a5e93f..892b63e6f 100644 --- a/internal/roadmap/coding/upstream-gap-review.md +++ b/internal/roadmap/coding/upstream-gap-review.md @@ -6,20 +6,20 @@ ## 结论 -Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 current 主链、Workbench projection、policy metadata、output refs、patch/file/command/test facts、Windows sandbox setup/readiness、双 mode restricted-token runner、P2-A 执行端有界输出捕获,以及 P2-B no-sandbox shell live process 都已落地。Windows runner schema v3 `7/7` 与复验 run 已形成平台 evidence。继续对比后,真正高价值遗漏集中在两条骨干和一个策略体验增强: +Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 current 主链、Workbench projection、policy metadata、output refs、patch/file/command/test facts、Windows sandbox setup/readiness、双 mode restricted-token runner、P2-A bounded output boundary 与 P2-B canonical sandbox-aware process owner 都已落地。Windows runner schema v3 `7/7` 与复验 run 已形成历史平台 evidence;当前 SHA 的 Windows 大输出复验仍需由 Windows runner 补证。继续对比后,真正高价值遗漏集中在正式 verifier/distribution evidence 和一个策略体验增强: -1. **统一进程生命周期 owner**:上游有可 write / interrupt / terminate / stream / poll 的 unified exec process;Lime 已有 process owner、本地 runner、App Server `executionProcess/*` 控制面与 no-sandbox shell live process,但 command/test 默认执行尚未切到 sandbox-aware control owner。 +1. **统一进程生命周期 owner**:上游有可 write / interrupt / terminate / stream / poll 的 unified exec process;Lime 已有 process owner、本地 runner、App Server `executionProcess/*` 控制面与 unified exec canonical command/test path,且回归证明带 `RuntimeToolExecutionAttempt` 的 workspace sandbox command 复用同一 control owner;P2-B owner 缺口已关闭。 2. **Windows sandbox 完整性**:Lime 已有 backend plan/readiness contract,以及 elevated sandbox-account、unelevated current-user restricted-token runner、短生命周期 ACL lease、TokenDefaultDacl、Job Object、ConPTY、显式 handle allowlist、Firewall/WFP 和有界 pipe reader;Windows runner schema v3 `7/7` 与复验 run 已完成当前平台 evidence。后续仅在 token/ACL/network 改动时回归。 3. **审批缓存与重试体验**:Lime 已有 `action.required` 和多来源策略,但缺 session-scope approval key、sandbox denied 后安全升级重试和规则草案沉淀。 -这些不是 UI polish,也不是旧路清理;它们直接决定 coding turn 在长命令、大输出、Windows 和审批重跑场景下是否可持续。 +这些不是 UI polish,也不是旧路清理;它们直接决定 coding turn 在大输出、Windows 和审批重跑场景下是否可持续。 ## 对比证据 | 上游参考能力 | 上游证据 | Lime current 状态 | 缺口判断 | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| 统一进程对象 | `core/src/unified_exec/process.rs` 暴露 write / terminate / interrupt / output receiver / state;`tools/runtimes/unified_exec.rs` 把审批、sandbox、network 与 process manager 接在一起。 | `tool-runtime::execution_process` 已提供 process snapshot、stdout/stderr delta、有界 retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已提供 `executionProcess/start`、`writeStdin`、`interrupt`、`terminate`、`status`、`drainOutput` 控制面;no-sandbox shell path 已在 Lime preflight 与 Agent registry permission/safety preflight 之后走 live process;`executionProcess/start` 已收紧为受控 current 入口,workspace sandbox backend required / enforced 时 fail-closed,不再允许 `cwd` 覆盖 policy 判定后的实际工作目录。 | P2-B no-sandbox 路径和 App Server 受控启动已落地;缺口收缩为“command/test 默认执行切到 sandbox-aware process runner/control owner,并接 UI 控制”。 | -| Head/tail 输出缓冲 | `core/src/unified_exec/head_tail_buffer.rs` 在进程读取阶段限制保留字节,保留头尾并记录 omitted bytes。 | `tool-runtime::execution_process::BoundedProcessOutput` 限制 live process retained output并记录 omitted bytes;Windows restricted-token blocking pipe reader 已复用同一 owner。 | current foundation 已覆盖 Windows 有界读取;仍需 Windows 真机大输出证据。 | +| 统一进程对象 | `core/src/unified_exec/process.rs` 暴露 write / terminate / interrupt / output receiver / state;`tools/runtimes/unified_exec.rs` 把审批、sandbox、network 与 process manager 接在一起。 | `tool-runtime::execution_process` 已提供 process snapshot、stdout/stderr delta、1 MiB head/tail retained output、stdin write、interrupt、terminate、status 与本地 process runner;App Server current 已提供 `executionProcess/start`、`writeStdin`、`interrupt`、`terminate`、`status`、`drainOutput` 控制面;unified exec canonical command/test path 已经消费 `RuntimeToolExecutionAttempt`,workspace sandbox 交互回归覆盖 snapshot、drain、stdin、terminal status 与 call identity。 | P2-B owner 与 P2-A output boundary 已落地;剩余是正式 verifier/distribution evidence。 | +| Head/tail 输出缓冲 | `core/src/unified_exec/head_tail_buffer.rs` 在进程读取阶段限制保留字节,保留头尾并记录 omitted bytes;`async_watcher.rs` 使用 64-slot broadcast 并在 UTF-8 scalar 边界发 delta。 | `tool-runtime::execution_process::BoundedProcessOutput` 统一限制 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 输出;默认保留 1 MiB,稳定保留 head/latest tail、精确记录 omitted bytes、插入 omission marker,并用 `ProcessOutputFramers` 在 UTF-8 边界发 delta。App Server replay 按进程隔离,unified exec observation 同样有界。 | P2-A `completed / current`;Windows 当前 SHA 的真机大输出仍需平台 evidence,不影响 owner 完成判定。 | | 审批缓存与重试 | `core/src/tools/sandboxing.rs` 有 approval cache、sandbox override、denied-read preservation;`tools/orchestrator.rs` 有 approval -> sandbox -> attempt -> denied retry。 | Lime 有 `ToolExecutionPolicyService` 多来源规则、`action.required`、审批后续跑测试和 sandbox blocked metadata。 | 缺“同一命令 approval key 复用 / sandbox denied 后升级重试 / proposed rule amendment”这一条统一执行语义。 | | 持久 capability SID | `windows-sandbox-rs/src/cap.rs` 按 workspace / writable root 持久化 SID,并用 canonical path key 去重。 | current foundation 使用每次运行创建的 capability SID 与短生命周期 ACL lease,尚未持久化。 | 在真机验证临时 lease 正确后,再裁决是否需要持久 SID owner。 | | TokenDefaultDacl | `windows-sandbox-rs/src/token.rs` 设置 token default DACL,避免受限 token 创建管道 / IPC 对象失败。 | current foundation 已设置 restricted token default DACL,但仅有非 Windows 测试和 SDK 源码核对。 | 需要 Windows 真机 PowerShell pipeline 与子进程 IPC 证据。 | @@ -33,17 +33,23 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c 目标:在 executor / tool outcome 进入 RuntimeCore 前就限制内存增长。 -状态:`partial / live-process retained output only`。 +状态:`completed / current`。 落点: - `lime-rs/crates/tool-runtime/src/execution_process.rs` +- `lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs` +- `lime-rs/crates/tool-runtime/src/unified_exec.rs` +- `lime-rs/crates/app-server/src/execution_process.rs` - `lime-rs/crates/app-server/src/runtime/output_refs.rs` 已落动作: -- live process retained output 已使用有界 buffer,并输出 `outputBytes / outputOmittedBytes / outputTruncated`。 -- Windows restricted-token blocking pipe reader 已接入同一有界输出语义;仍缺 Windows 真机大输出和 omitted-bytes 证据。 +- 本地 pipe、PTY、Seatbelt、Bubblewrap、Windows restricted runner 与远端 Environment 已共享 `BoundedProcessOutput`;默认保留 1 MiB,稳定保留 head/latest tail、精确记录 omitted bytes 并插入 omission marker。 +- reader 到 process handle 使用 64-slot bounded broadcast;慢消费者可 lag,但权威 terminal transcript 不丢失,未消费 delta 的大输出进程也能完成。 +- `ProcessOutputFramers` 按 stdout/stderr/combined 独立保留最多 3 个不完整 UTF-8 字节,完整 scalar 才进入 lifecycle delta,EOF flush 不完整后缀;raw byte、snapshot 与 omission 统计只计一次。 +- unified exec 每次 observation 使用同一有界 buffer;App Server replay 下沉到逐进程 queue,避免跨进程互相驱逐。 +- Windows restricted-token blocking pipe reader 已接入同一语义;当前 SHA 仍缺 Windows 真机大输出和 omitted-bytes 复验证据,不能用非 Windows 测试代替。 - RuntimeCore 继续负责 snapshot;executor 不直接写 App Server sidecar,避免跨层依赖。 收益:解决大输出在进入 output ref 之前压爆内存的问题,直接服务 command/test coding 主线。 @@ -52,11 +58,11 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c 目标:让 command/test execution 从“批处理终态”升级为“可观察、可中断、可续写”的进程对象。 -状态:`in_progress / no-sandbox live process done`。 +状态:`completed / canonical command-test sandbox-aware owner landed`。 落点: -- `lime-rs/crates/agent/src/agent_tools/execution/process.rs` +- `lime-rs/crates/tool-runtime/src/execution_process.rs` - `lime-rs/crates/app-server/src/runtime_backend/coding_events/command.rs` - `packages/agent-runtime-projection/src/coding.ts` @@ -66,7 +72,8 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c - 已把现有 shell batch bridge 接到 process metadata:`processId / executionProcessStatus / outputBytes / outputOmittedBytes / outputTruncated` 透传到 `tool.output.delta` / `command.output` metadata。 - 已通过 App Server current JSON-RPC 暴露 `executionProcess/start|writeStdin|interrupt|terminate|status|drainOutput`,并同步 protocol schema、processor、client 与 contract guard。 - no-sandbox shell path 已接入 live process:先过 `ToolExecutionDecision`,再复用 Agent `ToolRegistry::check_tool_permissions`,且需要 workspace sandbox backend 的命令继续走 Agent sandbox executor。 -- 下一刀把 command/test 默认执行切到 sandbox-aware `LocalExecutionProcessHandle` / execution process control owner,并让 Workbench UI 的停止、输入和状态刷新复用同一 current API。 +- `RuntimeToolExecutionAttempt` 的 workspace sandbox command 已通过 App Server `ExecutionProcessServer` 真实回归覆盖启动 snapshot、按 process cursor drain、stdin/control identity 与 terminal status;该证据与 unified exec/current orchestration wiring 一起关闭 command/test 默认 owner 迁移缺口。 +- P2-B 已由 unified exec 将 canonical command/test 默认执行接到 sandbox-aware `LocalExecutionProcessHandle` / App Server execution process control owner,Workbench UI 的停止、输入和状态刷新继续复用同一 current API;P2-A/P2-B 均已闭环,下一刀只补正式 verifier/distribution evidence。 收益:长任务、用户中断、实时日志、测试服务器、交互式 shell 才能成为产品能力,而不是一次性命令结果。 @@ -129,4 +136,4 @@ Lime 的 coding 骨架已经不是缺“能不能改文件”的问题;P1-P4 c ## 下一刀建议 -**P2-C Windows restricted-token runner 基线已完成。** readiness、setup mode、elevated sandbox-account、unelevated current-user restricted-token runner、ACL/Job/ConPTY、Firewall/WFP、warning lifecycle 和 readiness read-back 已收敛到 `tool-runtime` current owner;Windows runner schema v3 `7/7`、quality run 与 readiness 复验 run 是平台 evidence,不能用 macOS 结果替代。下一刀回到 P2-B 默认 command/test sandbox-aware process owner,不得用 no-sandbox process 绕过 sandbox。 +**P2-A/P2-B/P2-C current owner 均已完成。** 下一刀直接进入当前工作树的 Pier/DeepSWE verifier 与正式 distribution evidence;Windows runner schema v3 `7/7`、quality run 与 readiness 复验 run 只证明对应历史 SHA,当前 SHA 的 restricted-token 大输出仍必须在 Windows 真机复跑,不能用 macOS 结果替代,也不得用 no-sandbox process 绕过 sandbox。 diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock index 41af7e52d..4b3a88ae5 100644 --- a/lime-rs/Cargo.lock +++ b/lime-rs/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "agent-protocol" -version = "1.141.0" +version = "1.142.0" dependencies = [ "schemars", "serde", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "agent-runtime" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "anyhow", @@ -226,7 +226,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-server" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -292,7 +292,7 @@ dependencies = [ [[package]] name = "app-server-client" -version = "1.141.0" +version = "1.142.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -308,7 +308,7 @@ dependencies = [ [[package]] name = "app-server-daemon" -version = "1.141.0" +version = "1.142.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -320,7 +320,7 @@ dependencies = [ [[package]] name = "app-server-protocol" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "schemars", @@ -332,7 +332,7 @@ dependencies = [ [[package]] name = "app-server-test-client" -version = "1.141.0" +version = "1.142.0" dependencies = [ "anyhow", "app-server-client", @@ -343,7 +343,7 @@ dependencies = [ [[package]] name = "app-server-transport" -version = "1.141.0" +version = "1.142.0" dependencies = [ "app-server-protocol", "axum", @@ -979,7 +979,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cli" -version = "1.141.0" +version = "1.142.0" dependencies = [ "anyhow", "app-server-client", @@ -1031,7 +1031,7 @@ dependencies = [ [[package]] name = "code-mode" -version = "1.141.0" +version = "1.142.0" dependencies = [ "anyhow", "code-mode-host", @@ -1049,7 +1049,7 @@ dependencies = [ [[package]] name = "code-mode-host" -version = "1.141.0" +version = "1.142.0" dependencies = [ "axum", "code-mode-protocol", @@ -1065,7 +1065,7 @@ dependencies = [ [[package]] name = "code-mode-protocol" -version = "1.141.0" +version = "1.142.0" dependencies = [ "prost", "protoc-bin-vendored", @@ -1079,7 +1079,7 @@ dependencies = [ [[package]] name = "code-mode-runtime" -version = "1.141.0" +version = "1.142.0" dependencies = [ "base64 0.22.1", "code-mode-protocol", @@ -1398,8 +1398,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +source = "git+https://github.com/openai-oss-forks/crossterm?rev=45fecb9508105988f42fe6ff0441783ed3717f92#45fecb9508105988f42fe6ff0441783ed3717f92" dependencies = [ "bitflags 2.13.1", "crossterm_winapi", @@ -1889,7 +1888,7 @@ dependencies = [ [[package]] name = "document-preview" -version = "1.141.0" +version = "1.142.0" dependencies = [ "flate2", "tempfile", @@ -2100,7 +2099,7 @@ checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" [[package]] name = "execpolicy" -version = "1.141.0" +version = "1.142.0" dependencies = [ "anyhow", "clap", @@ -3493,7 +3492,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -3528,7 +3527,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.141.0" +version = "1.142.0" dependencies = [ "async-trait", "lime-core", @@ -3544,7 +3543,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "async-trait", @@ -3601,7 +3600,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.141.0" +version = "1.142.0" dependencies = [ "aes", "async-trait", @@ -3629,7 +3628,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.141.0" +version = "1.142.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -3651,7 +3650,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.141.0" +version = "1.142.0" dependencies = [ "chrono", "hex", @@ -3664,7 +3663,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.141.0" +version = "1.142.0" dependencies = [ "async-trait", "axum", @@ -3690,7 +3689,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.141.0" +version = "1.142.0" dependencies = [ "axum", "base64 0.22.1", @@ -3709,7 +3708,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.141.0" +version = "1.142.0" dependencies = [ "async-trait", "lime-core", @@ -3749,7 +3748,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "async-stream", @@ -3792,7 +3791,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.141.0" +version = "1.142.0" dependencies = [ "axum", "futures", @@ -3807,7 +3806,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.141.0" +version = "1.142.0" dependencies = [ "anyhow", "async-trait", @@ -3850,7 +3849,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.141.0" +version = "1.142.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -3872,7 +3871,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.141.0" +version = "1.142.0" dependencies = [ "axum", "chrono", @@ -4190,7 +4189,7 @@ dependencies = [ [[package]] name = "model-provider" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "anyhow", @@ -4980,7 +4979,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "patch-apply" -version = "1.141.0" +version = "1.142.0" dependencies = [ "tempfile", "thiserror 1.0.69", @@ -6003,7 +6002,7 @@ dependencies = [ [[package]] name = "runtime-core" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -7172,7 +7171,7 @@ dependencies = [ [[package]] name = "thread-store" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "chrono", @@ -7499,7 +7498,7 @@ dependencies = [ [[package]] name = "tool-runtime" -version = "1.141.0" +version = "1.142.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -7704,8 +7703,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "1.141.0" +version = "1.142.0" dependencies = [ + "agent-protocol", "anyhow", "app-server-client", "app-server-protocol", @@ -7727,12 +7727,15 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "tracing", "two-face", "unicode-segmentation", "unicode-width 0.2.2", "url", "urlencoding", + "vt100", "which 8.0.0", + "windows-sys 0.52.0", ] [[package]] @@ -8049,6 +8052,27 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width 0.2.2", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "wait-timeout" version = "0.2.1" diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml index 9b216dbb3..e717374a4 100644 --- a/lime-rs/Cargo.toml +++ b/lime-rs/Cargo.toml @@ -6,7 +6,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.141.0" +version = "1.142.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -165,6 +165,7 @@ rustls-pemfile = "2" # 终端 portable-pty = "0.8" +vt100 = "0.16.2" # App Server remote client transport tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "native-tls"] } @@ -234,3 +235,6 @@ version = "0.2" [workspace.lints.clippy] uninlined_format_args = "allow" string_slice = "warn" + +[patch.crates-io] +crossterm = { git = "https://github.com/openai-oss-forks/crossterm", rev = "45fecb9508105988f42fe6ff0441783ed3717f92" } diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_executor/orchestration.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_executor/orchestration.rs index 23df47680..ac4b4936b 100644 --- a/lime-rs/crates/agent/src/current_provider_turn/tool_executor/orchestration.rs +++ b/lime-rs/crates/agent/src/current_provider_turn/tool_executor/orchestration.rs @@ -270,9 +270,11 @@ async fn execute_current_tool_attempt( working_directory: request.context.working_directory().clone(), environment: request.context.environment().clone(), tool_call_id: attempt.identity().call_id().to_string(), + turn_id: attempt.identity().turn_id().to_string(), cancel_token: request.context.cancel_token().cloned(), turn_context: request.turn_context, attempt: Some(attempt), + output_sink: request.context.lifecycle_emitter().cloned(), }, ) .await; diff --git a/lime-rs/crates/app-server-client/src/lib.rs b/lime-rs/crates/app-server-client/src/lib.rs index 2db518cac..ec2f52c33 100644 --- a/lime-rs/crates/app-server-client/src/lib.rs +++ b/lime-rs/crates/app-server-client/src/lib.rs @@ -2,6 +2,7 @@ pub use app_server_protocol::app_server_method_catalog; pub use app_server_protocol::is_app_server_notification_method; pub use app_server_protocol::is_app_server_request_method; pub use app_server_protocol::protocol::v2::ServerNotification; +use app_server_protocol::protocol::v2::ServerRequest; pub use app_server_protocol::protocol::v2::ThreadRevertParams; pub use app_server_protocol::protocol::v2::ThreadRevertResponse; pub use app_server_protocol::protocol::v2::ThreadRevertedNotification; @@ -402,9 +403,17 @@ mod session; mod transport; pub use remote::{RemoteTransport, RemoteTransportConfig}; -pub use session::{ClientSession, RequestHandle, SessionError, SessionEvent}; +pub use session::{ClientSession, RequestHandle, SessionError}; pub use transport::{SessionTransport, StdioTransport, StdioTransportConfig}; +#[derive(Debug, Clone, PartialEq)] +pub enum AppServerEvent { + Lagged { skipped: usize }, + ServerNotification(Box), + ServerRequest(Box), + Disconnected { message: String }, +} + #[derive(Debug, Error)] pub enum ClientError { #[error("invalid request params: {0}")] diff --git a/lime-rs/crates/app-server-client/src/session.rs b/lime-rs/crates/app-server-client/src/session.rs index feced6ce6..6b6af6163 100644 --- a/lime-rs/crates/app-server-client/src/session.rs +++ b/lime-rs/crates/app-server-client/src/session.rs @@ -1,8 +1,12 @@ -use crate::{AppServerClient, ClientError, ClientEvent}; -use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest}; +use crate::{AppServerClient, AppServerEvent, ClientError, ClientEvent}; +#[cfg(test)] +use app_server_protocol::protocol::v2::ServerNotification; +use app_server_protocol::protocol::v2::ServerRequest; +#[cfg(test)] +use app_server_protocol::JsonRpcRequest; use app_server_protocol::{ InitializeParams, InitializeResponse, JsonRpcError, JsonRpcErrorResponse, JsonRpcMessage, - JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, RequestId, + JsonRpcNotification, JsonRpcResponse, RequestId, }; use serde::de::DeserializeOwned; use serde::Serialize; @@ -39,15 +43,6 @@ pub enum SessionError { UnsupportedProtocolVersion { actual: String, supported: String }, } -#[derive(Debug, Clone, PartialEq)] -pub enum SessionEvent { - Notification(Box), - RawNotification(JsonRpcNotification), - ServerRequest(Box), - RawServerRequest(JsonRpcRequest), - Disconnected { message: String }, -} - enum SessionCommand { Request { method: String, @@ -158,7 +153,7 @@ impl RequestHandle { pub struct ClientSession { request_handle: RequestHandle, - event_rx: mpsc::UnboundedReceiver, + event_rx: mpsc::UnboundedReceiver, worker: tokio::task::JoinHandle<()>, initialize_response: InitializeResponse, } @@ -232,7 +227,7 @@ impl ClientSession { &self.initialize_response } - pub async fn next_event(&mut self) -> Option { + pub async fn next_event(&mut self) -> Option { self.event_rx.recv().await } @@ -273,7 +268,7 @@ async fn stop_worker(request_handle: &RequestHandle, worker: tokio::task::JoinHa async fn run_session( mut transport: T, mut command_rx: mpsc::Receiver, - event_tx: mpsc::UnboundedSender, + event_tx: mpsc::UnboundedSender, ) { let mut client = AppServerClient::new(); let mut pending = HashMap::::new(); @@ -366,7 +361,13 @@ async fn run_session( let _ = transport.close().await; break; } - handle_message(message, &event_tx, &mut pending) + if let Some(response) = handle_message(message, &event_tx, &mut pending) { + if let Err(error) = transport.send(response).await { + disconnect(&event_tx, &mut pending, error.to_string()); + let _ = transport.close().await; + break; + } + } } Ok(None) => { disconnect(&event_tx, &mut pending, "app-server transport closed".to_string()); @@ -413,9 +414,9 @@ fn validate_initialize_response( fn handle_message( message: JsonRpcMessage, - event_tx: &mpsc::UnboundedSender, + event_tx: &mpsc::UnboundedSender, pending: &mut HashMap, -) { +) -> Option { match message { JsonRpcMessage::Response(response) => { if let Some(pending) = pending.remove(&response.id) { @@ -432,37 +433,41 @@ fn handle_message( } message => match AppServerClient::event(message) { Ok(ClientEvent::Lifecycle(notification)) => { - let _ = event_tx.send(SessionEvent::Notification(notification)); - } - Ok( - ClientEvent::AgentSession(notification) | ClientEvent::Notification(notification), - ) => { - let _ = event_tx.send(SessionEvent::RawNotification(notification)); - } - Ok(ClientEvent::Request(request)) => { - let event = ServerRequest::try_from(request.clone()) - .map(Box::new) - .map(SessionEvent::ServerRequest) - .unwrap_or(SessionEvent::RawServerRequest(request)); - let _ = event_tx.send(event); + let _ = event_tx.send(AppServerEvent::ServerNotification(notification)); } + Ok(ClientEvent::AgentSession(_) | ClientEvent::Notification(_)) => {} + Ok(ClientEvent::Request(request)) => match ServerRequest::try_from(request.clone()) { + Ok(request) => { + let _ = event_tx.send(AppServerEvent::ServerRequest(Box::new(request))); + } + Err(_) => { + return Some(JsonRpcMessage::Error(JsonRpcErrorResponse { + id: request.id, + error: JsonRpcError::new( + app_server_protocol::error_codes::METHOD_NOT_FOUND, + format!("unsupported app-server request `{}`", request.method), + ), + })); + } + }, Ok(ClientEvent::Response(_) | ClientEvent::Error(_)) => {} Err(error) => { - let _ = event_tx.send(SessionEvent::Disconnected { + let _ = event_tx.send(AppServerEvent::Disconnected { message: error.to_string(), }); } }, } + None } fn disconnect( - event_tx: &mpsc::UnboundedSender, + event_tx: &mpsc::UnboundedSender, pending: &mut HashMap, message: String, ) { fail_pending(pending, &message); - let _ = event_tx.send(SessionEvent::Disconnected { message }); + let _ = event_tx.send(AppServerEvent::Disconnected { message }); } fn fail_pending(pending: &mut HashMap, message: &str) { @@ -589,10 +594,16 @@ mod tests { panic!("expected request"); }; server_tx - .send(JsonRpcMessage::Notification(JsonRpcNotification::new( - "terminal/test", - Some(serde_json::json!({ "sequence": 1 })), - ))) + .send(JsonRpcMessage::Notification( + ServerNotification::Warning( + app_server_protocol::protocol::v2::WarningNotification { + thread_id: Some("thread-1".to_string()), + message: "warning".to_string(), + code: None, + }, + ) + .into(), + )) .expect("send notification"); server_tx .send(JsonRpcMessage::Response( @@ -614,12 +625,51 @@ mod tests { assert_eq!(response, serde_json::json!({ "value": 7 })); assert!(matches!( session.next_event().await, - Some(SessionEvent::RawNotification(notification)) - if notification.method == "terminal/test" + Some(AppServerEvent::ServerNotification(notification)) + if matches!(*notification, ServerNotification::Warning(_)) )); server.await.expect("server task"); } + #[tokio::test] + async fn remote_unknown_server_request_is_rejected() { + let (transport, mut server_rx, server_tx, close_rx) = transport_pair(); + let (rejected_tx, rejected_rx) = oneshot::channel(); + let server = tokio::spawn(async move { + complete_handshake(&mut server_rx, &server_tx).await; + let request_id = RequestId::String("srv-unknown".to_string()); + server_tx + .send(JsonRpcMessage::Request(JsonRpcRequest::new( + request_id.clone(), + "thread/unknown", + None, + ))) + .expect("send server request"); + + let JsonRpcMessage::Error(response) = server_rx.recv().await.expect("response") else { + panic!("expected JSON-RPC error response"); + }; + assert_eq!(response.id, request_id); + assert_eq!( + response.error.code, + app_server_protocol::error_codes::METHOD_NOT_FOUND + ); + assert_eq!( + response.error.message, + "unsupported app-server request `thread/unknown`" + ); + rejected_tx.send(()).expect("report rejected request"); + close_rx.await.expect("client shutdown closes transport"); + }); + let session = ClientSession::start(transport, initialize_params()) + .await + .expect("session"); + + rejected_rx.await.expect("unknown request rejected"); + session.shutdown().await.expect("shutdown"); + server.await.expect("server task"); + } + #[tokio::test] async fn reverse_request_is_typed_and_can_be_resolved() { let (transport, mut server_rx, server_tx, _close_rx) = transport_pair(); @@ -644,7 +694,7 @@ mod tests { .await .expect("session"); let request = session.next_event().await.expect("server request"); - let SessionEvent::ServerRequest(request) = request else { + let AppServerEvent::ServerRequest(request) = request else { panic!("expected typed current time request"); }; let ServerRequest::CurrentTimeRead { id, params } = *request else { diff --git a/lime-rs/crates/app-server/src/execution_process.rs b/lime-rs/crates/app-server/src/execution_process.rs index 7f48ea173..b7e0c3707 100644 --- a/lime-rs/crates/app-server/src/execution_process.rs +++ b/lime-rs/crates/app-server/src/execution_process.rs @@ -23,9 +23,9 @@ use tool_runtime::execution_policy::{ use tool_runtime::execution_policy_service::ToolExecutionResolverInput; use tool_runtime::execution_process::{ live::{LiveExecutionOutputBatch, LiveExecutionOutputQuery, LiveExecutionRequest}, - start_local_execution_process, ExecutionOutputDelta, ExecutionProcessSnapshot, - LiveExecutionProcessRegistry, LocalExecutionProcessControlHandle, LocalExecutionRequest, - LocalExecutionSandbox, + start_local_execution_process, BoundedProcessOutput, ExecutionOutputDelta, + ExecutionProcessSnapshot, LiveExecutionProcessRegistry, LocalExecutionProcessControlHandle, + LocalExecutionRequest, LocalExecutionSandbox, ProcessOutputFramers, }; use tool_runtime::sandbox::{ plan_sandbox_backend, SandboxBackendPlanInput, SandboxBackendPlatform, @@ -48,8 +48,6 @@ pub struct ExecutionProcessServer { #[derive(Debug)] struct ExecutionProcessState { processes: HashMap, - output: VecDeque, - output_bytes: usize, next_background_process_id: u64, } @@ -57,6 +55,11 @@ struct ExecutionProcessState { struct ExecutionProcessEntry { handle: Option, remote_control: Option, + output_buffer: Option, + output_framers: Option, + output: VecDeque, + output_bytes: usize, + next_output_sequence: u64, snapshot: Option, final_snapshot: Option, background: Option, @@ -89,8 +92,6 @@ impl Default for ExecutionProcessState { fn default() -> Self { Self { processes: HashMap::new(), - output: VecDeque::new(), - output_bytes: 0, next_background_process_id: 1, } } @@ -166,6 +167,11 @@ impl ExecutionProcessServer { ExecutionProcessEntry { handle: if is_terminal { None } else { Some(handle) }, remote_control: None, + output_buffer: None, + output_framers: None, + output: VecDeque::new(), + output_bytes: 0, + next_output_sequence: 1, snapshot: Some(snapshot.clone()), final_snapshot: if is_terminal { Some(snapshot) } else { None }, background: None, @@ -176,13 +182,14 @@ impl ExecutionProcessServer { pub fn record_process_output( &self, - delta: ExecutionOutputDelta, + mut delta: ExecutionOutputDelta, ) -> Result<(), ExecutionProcessError> { let mut state = self.inner.lock().map_err(|_| ExecutionProcessError::Lock)?; - if !state.processes.contains_key(&delta.process_id) { + let Some(entry) = state.processes.get_mut(&delta.process_id) else { return Err(ExecutionProcessError::ProcessNotFound(delta.process_id)); - } - push_output(&mut state, delta); + }; + update_snapshot_output(entry, &mut delta); + push_output(entry, delta); Ok(()) } @@ -297,6 +304,11 @@ impl ExecutionProcessServer { ExecutionProcessEntry { handle: None, remote_control: None, + output_buffer: None, + output_framers: None, + output: VecDeque::new(), + output_bytes: 0, + next_output_sequence: 1, snapshot: None, final_snapshot: None, background, @@ -349,7 +361,9 @@ impl ExecutionProcessServer { tokio::spawn(async move { while let Some(delta) = handle.recv_output().await { if let Ok(mut state) = inner.lock() { - push_output(&mut state, delta); + if let Some(entry) = state.processes.get_mut(&delta.process_id) { + push_output(entry, delta); + } } } let final_snapshot = handle.wait().await.ok(); @@ -446,6 +460,11 @@ impl ExecutionProcessServer { ExecutionProcessEntry { handle: None, remote_control: Some(RemoteProcessControl { commands }), + output_buffer: Some(BoundedProcessOutput::default()), + output_framers: Some(ProcessOutputFramers::default()), + output: VecDeque::new(), + output_bytes: 0, + next_output_sequence: 1, snapshot: Some(snapshot.clone()), final_snapshot: None, background, @@ -719,25 +738,35 @@ impl ExecutionProcessServer { .unwrap_or(usize::MAX); let after_sequence = query.after_sequence.unwrap_or_default(); let state = self.inner.lock().map_err(|_| ExecutionProcessError::Lock)?; + let process_id = query + .process_id + .as_deref() + .filter(|process_id| !process_id.trim().is_empty()) + .ok_or_else(|| { + ExecutionProcessError::Control( + "execution process output requires a process id".to_string(), + ) + })?; + let entry = state + .processes + .get(process_id) + .ok_or_else(|| ExecutionProcessError::ProcessNotFound(process_id.to_string()))?; let mut deltas = Vec::new(); let mut bytes = 0usize; let mut next_sequence = query.after_sequence; - for delta in state.output.iter() { + for delta in entry.output.iter() { if deltas.len() >= limit { break; } - if query - .process_id - .as_ref() - .is_some_and(|process_id| process_id != &delta.process_id) - { - continue; - } if delta.sequence <= after_sequence { continue; } - let delta_bytes = delta.delta.len(); + let delta_bytes = if delta.raw_bytes.is_empty() { + delta.delta.len() + } else { + delta.raw_bytes.len() + }; if !deltas.is_empty() && bytes.saturating_add(delta_bytes) > max_bytes { break; } @@ -1209,33 +1238,35 @@ async fn run_remote_process( } else { tool_runtime::execution_process::ExecutionOutputKind::Stdout }; - let delta = tool_runtime::execution_process::ExecutionOutputDelta { - process_id: process_id.clone(), - tool_id: tool_id.clone(), - // Environment process cursors are zero-based; the local - // execution stream reserves sequence 0 as the initial cursor. - sequence: chunk.seq.saturating_add(1), - kind, - delta: String::from_utf8_lossy(&raw_bytes).into_owned(), - bytes: raw_bytes.len() as u64, - omitted_bytes: 0, - truncated: false, - raw_bytes, - }; if let Ok(mut state) = inner.lock() { if let Some(entry) = state.processes.get_mut(&process_id) { - if let Some(snapshot) = entry.snapshot.as_mut() { - snapshot.output_bytes = snapshot.output_bytes.saturating_add(delta.bytes); - snapshot.retained_output.push_str(&delta.delta); - if snapshot.retained_output.len() > 128 * 1024 { - let trim = snapshot.retained_output.len() - 128 * 1024; - snapshot.retained_output.drain(..trim); - snapshot.output_omitted_bytes = snapshot.output_omitted_bytes.saturating_add(trim as u64); - snapshot.output_truncated = true; - } + capture_snapshot_output(entry, &raw_bytes); + // Environment cursors are zero-based; local output + // cursors reserve zero as the initial position. + entry.next_output_sequence = entry + .next_output_sequence + .max(chunk.seq.saturating_add(1)); + let frame = entry + .output_framers + .as_mut() + .map(|framers| framers.push(kind, &raw_bytes)) + .unwrap_or(raw_bytes); + if !frame.is_empty() { + let mut delta = ExecutionOutputDelta { + process_id: process_id.clone(), + tool_id: tool_id.clone(), + sequence: take_output_sequence(entry), + kind, + delta: String::from_utf8_lossy(&frame).into_owned(), + bytes: 0, + omitted_bytes: 0, + truncated: false, + raw_bytes: frame, + }; + apply_snapshot_output_metadata(entry, &mut delta); + push_output(entry, delta); } } - push_output(&mut state, delta); } } if response.exited || response.closed || response.failure.is_some() || response.sandbox_denied { @@ -1282,6 +1313,7 @@ async fn finish_remote_process( ) { if let Ok(mut state) = inner.lock() { if let Some(entry) = state.processes.get_mut(process_id) { + flush_output_framers(entry, process_id, tool_id); let mut snapshot = entry .snapshot .clone() @@ -1313,19 +1345,92 @@ async fn finish_remote_process( } } -fn push_output(state: &mut ExecutionProcessState, mut delta: ExecutionOutputDelta) { - delta.raw_bytes.clear(); - state.output_bytes = state.output_bytes.saturating_add(delta.delta.len()); - state.output.push_back(delta); - while state.output.len() > OUTPUT_EVENT_CAP || state.output_bytes > OUTPUT_BYTE_CAP { - let Some(evicted) = state.output.pop_front() else { - state.output_bytes = 0; +fn push_output(entry: &mut ExecutionProcessEntry, delta: ExecutionOutputDelta) { + let delta_bytes = if delta.raw_bytes.is_empty() { + delta.delta.len() + } else { + delta.raw_bytes.len() + }; + entry.next_output_sequence = entry + .next_output_sequence + .max(delta.sequence.saturating_add(1)); + entry.output_bytes = entry.output_bytes.saturating_add(delta_bytes); + entry.output.push_back(delta); + while entry.output.len() > OUTPUT_EVENT_CAP || entry.output_bytes > OUTPUT_BYTE_CAP { + let Some(evicted) = entry.output.pop_front() else { + entry.output_bytes = 0; break; }; - state.output_bytes = state.output_bytes.saturating_sub(evicted.delta.len()); + let evicted_bytes = if evicted.raw_bytes.is_empty() { + evicted.delta.len() + } else { + evicted.raw_bytes.len() + }; + entry.output_bytes = entry.output_bytes.saturating_sub(evicted_bytes); } } +fn update_snapshot_output(entry: &mut ExecutionProcessEntry, delta: &mut ExecutionOutputDelta) { + let bytes = if delta.raw_bytes.is_empty() { + delta.delta.as_bytes() + } else { + delta.raw_bytes.as_slice() + }; + capture_snapshot_output(entry, bytes); + apply_snapshot_output_metadata(entry, delta); +} + +fn capture_snapshot_output(entry: &mut ExecutionProcessEntry, bytes: &[u8]) { + let Some(buffer) = entry.output_buffer.as_mut() else { + return; + }; + buffer.push(bytes); + let snapshot = buffer.snapshot(); + if let Some(process_snapshot) = entry.snapshot.as_mut() { + process_snapshot.output_bytes = snapshot.bytes; + process_snapshot.output_omitted_bytes = snapshot.omitted_bytes; + process_snapshot.output_truncated = snapshot.truncated; + process_snapshot.retained_output = snapshot.text; + } +} + +fn apply_snapshot_output_metadata(entry: &ExecutionProcessEntry, delta: &mut ExecutionOutputDelta) { + let Some(buffer) = entry.output_buffer.as_ref() else { + return; + }; + let snapshot = buffer.snapshot(); + delta.bytes = snapshot.bytes; + delta.omitted_bytes = snapshot.omitted_bytes; + delta.truncated = snapshot.truncated; +} + +fn flush_output_framers(entry: &mut ExecutionProcessEntry, process_id: &str, tool_id: &str) { + let Some(mut framers) = entry.output_framers.take() else { + return; + }; + for (kind, frame) in framers.finish_all() { + let mut delta = ExecutionOutputDelta { + process_id: process_id.to_string(), + tool_id: tool_id.to_string(), + sequence: take_output_sequence(entry), + kind, + delta: String::from_utf8_lossy(&frame).into_owned(), + bytes: 0, + omitted_bytes: 0, + truncated: false, + raw_bytes: frame, + }; + apply_snapshot_output_metadata(entry, &mut delta); + push_output(entry, delta); + } +} + +fn take_output_sequence(entry: &mut ExecutionProcessEntry) -> u64 { + let sequence = entry.next_output_sequence; + entry.next_output_sequence = entry.next_output_sequence.saturating_add(1); + sequence +} + #[cfg(test)] #[path = "execution_process/tests.rs"] mod tests; diff --git a/lime-rs/crates/app-server/src/execution_process/tests.rs b/lime-rs/crates/app-server/src/execution_process/tests.rs index 23a53d517..797e456f4 100644 --- a/lime-rs/crates/app-server/src/execution_process/tests.rs +++ b/lime-rs/crates/app-server/src/execution_process/tests.rs @@ -19,7 +19,7 @@ use tool_runtime::execution_process::{ LiveExecutionOutputBatch, LiveExecutionOutputQuery, LiveExecutionRequest, RuntimeLiveExecutionGateway, }, - ExecutionProcessStatus, + ExecutionProcessStatus, DEFAULT_OUTPUT_RETAIN_BYTES, }; use tool_runtime::tool_executor::{RuntimeToolExecutionIdentity, RuntimeToolExecutionResult}; use tool_runtime::unified_exec::{ @@ -88,6 +88,47 @@ impl RuntimeToolAttemptRunner for ProcessAttemptRunner { } } +struct InteractiveProcessAttemptRunner { + server: ExecutionProcessServer, + process_id: &'static str, +} + +impl RuntimeToolAttemptRunner for InteractiveProcessAttemptRunner { + fn run<'a>(&'a self, attempt: RuntimeToolExecutionAttempt) -> RuntimeToolAttemptFuture<'a> { + Box::pin(async move { + let snapshot = RuntimeLiveExecutionGateway::start_process( + &self.server, + "test-thread", + "interactive sandbox command", + LiveExecutionRequest { + process_id: self.process_id.to_string(), + tool_id: attempt.identity().call_id().to_string(), + tool_name: "exec_command".to_string(), + environment_id: "local".to_string(), + command: interactive_stdin_command(), + working_directory: current_directory(), + tty: false, + approval_policy: Some("on-request".to_string()), + sandbox_policy: attempt + .effective_sandbox_policy() + .label() + .map(str::to_string), + runtime_metadata: None, + env: HashMap::new(), + attempt: Some(attempt), + }, + ) + .await?; + Ok(RuntimeToolExecutionResult::new( + true, + snapshot.process_id, + None, + HashMap::new(), + )) + }) + } +} + fn process_orchestration_input( initial_approval: RuntimeToolInitialApproval, sandbox_policy: RuntimeToolSandboxPolicy, @@ -140,6 +181,61 @@ async fn orchestrated_process_does_not_repeat_policy_approval() { assert_eq!(snapshot.status, ExecutionProcessStatus::Exited); } +#[tokio::test] +async fn orchestrated_sandbox_process_reuses_execution_process_control_owner() { + let runner = InteractiveProcessAttemptRunner { + server: ExecutionProcessServer::default(), + process_id: "orchestrated-sandbox-control-process", + }; + let result = orchestrate_runtime_tool_execution( + process_orchestration_input( + RuntimeToolInitialApproval::Required(RuntimeToolApprovalKind::User), + RuntimeToolSandboxPolicy::WorkspaceWrite, + None, + false, + ), + &RecordingProcessApprovals::default(), + &runner, + ) + .await + .expect("sandbox process should start after approval"); + + assert_eq!(result.output, runner.process_id); + assert_eq!(result.metadata.get("toolAttemptNumber"), Some(&json!(1))); + assert_eq!( + result.metadata.get("effectiveSandboxPolicy"), + Some(&json!("workspace-write")) + ); + + let process_id = runner.process_id; + let initial = runner + .server + .status(process_id) + .expect("sandbox process snapshot should be available"); + assert_eq!(initial.status, ExecutionProcessStatus::Running); + assert_eq!(initial.tool_id, "orchestrated-call"); + + let ready = wait_for_output(&runner.server, process_id, "READY").await; + assert!(ready + .deltas + .iter() + .all(|delta| delta.tool_id == "orchestrated-call")); + runner + .server + .write_stdin(process_id, b"from-control\n") + .expect("sandbox process stdin should use the shared control owner"); + + let received = wait_for_output(&runner.server, process_id, "RECEIVED:from-control").await; + assert!(received + .deltas + .iter() + .all(|delta| delta.tool_id == "orchestrated-call")); + let final_snapshot = wait_for_terminal_snapshot(&runner.server, process_id).await; + assert_eq!(final_snapshot.status, ExecutionProcessStatus::Exited); + assert_eq!(final_snapshot.exit_code, Some(0)); + assert_eq!(final_snapshot.tool_id, "orchestrated-call"); +} + #[tokio::test] async fn managed_network_denial_precedes_sandbox_fallback_and_retries_with_network_grant() { let approvals = RecordingProcessApprovals::default(); @@ -233,9 +329,11 @@ async fn unified_exec_yields_active_session_then_poll_observes_terminal_process( working_directory: current_directory(), environment: HashMap::new(), tool_call_id: "unified-exec-call".to_string(), + turn_id: "unified-exec-turn".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, }, ) .await @@ -263,9 +361,11 @@ async fn unified_exec_yields_active_session_then_poll_observes_terminal_process( working_directory: current_directory(), environment: HashMap::new(), tool_call_id: "unified-exec-poll".to_string(), + turn_id: "unified-exec-turn".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, }, ) .await @@ -308,9 +408,11 @@ async fn unified_exec_reports_silent_non_zero_exit_as_terminal() { working_directory: current_directory(), environment: HashMap::new(), tool_call_id: "unified-exec-silent-failure-call".to_string(), + turn_id: "unified-exec-silent-failure-turn".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, }, ) .await @@ -354,9 +456,11 @@ async fn unified_exec_starts_short_command_after_repeated_terminal_commands() { working_directory: current_directory(), environment: HashMap::new(), tool_call_id: format!("unified-exec-sequence-{index}"), + turn_id: "unified-exec-sequence-turn".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, }, ), ) @@ -385,9 +489,11 @@ async fn unified_exec_starts_short_command_after_repeated_terminal_commands() { working_directory: current_directory(), environment: HashMap::new(), tool_call_id: "unified-exec-sequence-read".to_string(), + turn_id: "unified-exec-sequence-turn".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, }, ), ) @@ -434,6 +540,15 @@ async fn execution_process_server_uses_environment_process_transport() { .await .expect("remote exec fixture bind"); let address = listener.local_addr().expect("remote exec fixture address"); + let remote_output = format!( + "REMOTE-HEAD\n\u{1f600}\n{}\nREMOTE-TAIL", + "x".repeat(DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024) + ); + let fixture_output = remote_output.clone(); + let fixture_split = fixture_output + .find('\u{1f600}') + .expect("remote fixture emoji") + + 2; let read_count = Arc::new(AtomicUsize::new(0)); let fixture_read_count = Arc::clone(&read_count); let fixture = tokio::spawn(async move { @@ -466,12 +581,19 @@ async fn execution_process_server_uses_environment_process_transport() { let read = fixture_read_count.fetch_add(1, Ordering::SeqCst); if read == 0 { json!({ - "chunks": [{ - "seq": 0, - "stream": "stdout", - "chunk": base64::engine::general_purpose::STANDARD.encode("remote-output") - }], - "nextSeq": 1, + "chunks": [ + { + "seq": 0, + "stream": "stdout", + "chunk": base64::engine::general_purpose::STANDARD.encode(&fixture_output.as_bytes()[..fixture_split]) + }, + { + "seq": 1, + "stream": "stdout", + "chunk": base64::engine::general_purpose::STANDARD.encode(&fixture_output.as_bytes()[fixture_split..]) + } + ], + "nextSeq": 2, "exited": true, "exitCode": 0, "closed": false, @@ -549,14 +671,42 @@ async fn execution_process_server_uses_environment_process_transport() { .signal("process-remote-fixture") .expect("remote signal should use process/signal"); - let output = wait_for_output(&server, "process-remote-fixture", "remote-output").await; + let output = wait_for_output(&server, "process-remote-fixture", "REMOTE-TAIL").await; + assert_eq!( + output + .deltas + .iter() + .map(|delta| delta.delta.as_str()) + .collect::(), + remote_output + ); assert!(output .deltas .iter() - .any(|delta| delta.delta.contains("remote-output"))); + .all(|delta| !delta.delta.contains('\u{fffd}'))); + let delta = output.deltas.last().expect("remote output delta"); + assert_eq!(delta.bytes, remote_output.len() as u64); + assert_eq!( + delta.omitted_bytes, + remote_output + .len() + .saturating_sub(DEFAULT_OUTPUT_RETAIN_BYTES) as u64 + ); + assert!(delta.truncated); let snapshot = wait_for_terminal_snapshot(&server, "process-remote-fixture").await; assert_eq!(snapshot.status, ExecutionProcessStatus::Exited); assert_eq!(snapshot.exit_code, Some(0)); + assert_eq!(snapshot.output_bytes, remote_output.len() as u64); + assert_eq!(snapshot.output_omitted_bytes, delta.omitted_bytes); + assert!(snapshot.output_truncated); + let omission_marker = format!("... {} bytes omitted ...", delta.omitted_bytes); + assert_eq!( + snapshot.retained_output.len(), + DEFAULT_OUTPUT_RETAIN_BYTES + omission_marker.len() + 2 + ); + assert!(snapshot.retained_output.starts_with("REMOTE-HEAD\n")); + assert!(snapshot.retained_output.contains(&omission_marker)); + assert!(snapshot.retained_output.ends_with("\nREMOTE-TAIL")); assert!(read_count.load(Ordering::SeqCst) >= 1); fixture.abort(); @@ -616,6 +766,63 @@ async fn execution_process_output_replays_until_cursor_advances() { assert_eq!(after_cursor.next_sequence, Some(cursor)); } +#[test] +fn execution_process_output_replay_is_isolated_per_process() { + let server = ExecutionProcessServer::default(); + let entry = || ExecutionProcessEntry { + handle: None, + remote_control: None, + output_buffer: None, + output_framers: None, + output: VecDeque::new(), + output_bytes: 0, + next_output_sequence: 1, + snapshot: None, + final_snapshot: None, + background: None, + }; + { + let mut state = server.inner.lock().expect("execution process state"); + state.processes.insert("first".to_string(), entry()); + state.processes.insert("second".to_string(), entry()); + push_output( + state.processes.get_mut("first").expect("first process"), + replay_delta("first", 1, "first-output"), + ); + for sequence in 1..=(OUTPUT_EVENT_CAP as u64 + 1) { + push_output( + state.processes.get_mut("second").expect("second process"), + replay_delta("second", sequence, "x"), + ); + } + } + + let first = server + .drain_output(LiveExecutionOutputQuery { + process_id: Some("first".to_string()), + after_sequence: None, + limit: None, + max_bytes: None, + }) + .expect("first process output"); + assert_eq!(first.deltas.len(), 1); + assert_eq!(first.deltas[0].delta, "first-output"); +} + +fn replay_delta(process_id: &str, sequence: u64, output: &str) -> ExecutionOutputDelta { + ExecutionOutputDelta { + process_id: process_id.to_string(), + tool_id: format!("tool-{process_id}"), + sequence, + kind: tool_runtime::execution_process::ExecutionOutputKind::Stdout, + delta: output.to_string(), + bytes: output.len() as u64, + omitted_bytes: 0, + truncated: false, + raw_bytes: output.as_bytes().to_vec(), + } +} + #[tokio::test] async fn execution_process_server_tracks_registered_live_process() { let server = ExecutionProcessServer::default(); @@ -863,6 +1070,25 @@ fn shell_output_command(output: &str) -> Vec { } } +fn interactive_stdin_command() -> Vec { + if cfg!(windows) { + vec![ + "cmd.exe".to_string(), + "/D".to_string(), + "/V:ON".to_string(), + "/S".to_string(), + "/C".to_string(), + "echo|set /p=READY & set /p value= & echo RECEIVED:!value!".to_string(), + ] + } else { + vec![ + "sh".to_string(), + "-c".to_string(), + "printf READY; IFS= read -r value; printf 'RECEIVED:%s' \"$value\"".to_string(), + ] + } +} + #[test] fn remote_sandbox_context_lowers_codex_environment_wire() { let cwd = app_server_protocol::protocol::v2::PathUri::from_host_path(current_directory()) diff --git a/lime-rs/crates/app-server/src/main.rs b/lime-rs/crates/app-server/src/main.rs index 74489d9a4..3423ca879 100644 --- a/lime-rs/crates/app-server/src/main.rs +++ b/lime-rs/crates/app-server/src/main.rs @@ -506,10 +506,7 @@ fn parse_args_from_with_env( while let Some(arg) = args.next() { if let Some(value) = arg.strip_prefix("--data-dir=") { - if value.is_empty() { - anyhow::bail!("--data-dir requires a path"); - } - data_dir = Some(PathBuf::from(value)); + data_dir = Some(validate_data_dir(PathBuf::from(value), "--data-dir")?); data_dir_from_cli = true; continue; } @@ -567,10 +564,13 @@ fn parse_args_from_with_env( ); } "--data-dir" => { - data_dir = - Some(PathBuf::from(args.next().ok_or_else(|| { - anyhow::anyhow!("--data-dir requires a path") - })?)); + data_dir = Some(validate_data_dir( + PathBuf::from( + args.next() + .ok_or_else(|| anyhow::anyhow!("--data-dir requires a path"))?, + ), + "--data-dir", + )?); data_dir_from_cli = true; } "--app-data-dir" => { @@ -595,6 +595,12 @@ fn parse_args_from_with_env( } } + if !data_dir_from_cli { + if let Some(path) = data_dir.take() { + data_dir = Some(validate_data_dir(path, APP_SERVER_DATA_DIR_ENV)?); + } + } + if model_control_source.is_some() && !data_dir_from_cli { anyhow::bail!("--model-control-source requires an explicit --data-dir"); } @@ -612,6 +618,19 @@ fn parse_args_from_with_env( }) } +fn validate_data_dir(path: PathBuf, source: &str) -> anyhow::Result { + if path.as_os_str().is_empty() { + anyhow::bail!("{source} requires a path"); + } + if path == Path::new("undefined") || path == Path::new("null") { + anyhow::bail!("{source} has an invalid path"); + } + if !path.is_absolute() { + anyhow::bail!("{source} must be an absolute path"); + } + Ok(path) +} + fn load_app_policy_source(path: &str) -> anyhow::Result { let json = std::fs::read_to_string(path) .map_err(|error| anyhow::anyhow!("failed to read app policy {path}: {error}"))?; @@ -806,6 +825,31 @@ mod tests { assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/env-app-server"))); } + #[test] + fn parse_args_rejects_invalid_data_dir_values() { + for args in [ + vec!["--data-dir", "undefined"], + vec!["--data-dir=null"], + vec!["--data-dir", "relative/app-server"], + ] { + let error = parse_args_from(args.into_iter().map(str::to_string)) + .expect_err("invalid data dir must fail closed"); + assert!(error.to_string().contains("data-dir")); + } + + let error = parse_args_from_with_env(Vec::new(), Some(OsString::from("undefined")), None) + .expect_err("invalid data dir environment must fail closed"); + assert!(error.to_string().contains("APP_SERVER_DATA_DIR")); + + let config = parse_args_from_with_env( + ["--data-dir", "/tmp/cli-app-server"].map(str::to_string), + Some(OsString::from("undefined")), + None, + ) + .expect("explicit CLI data dir must override invalid environment"); + assert_eq!(config.data_dir, Some(PathBuf::from("/tmp/cli-app-server"))); + } + #[test] fn parse_args_rejects_retired_product_db_migration_cleanup_policy() { let error = diff --git a/lime-rs/crates/app-server/src/runtime/session_shell.rs b/lime-rs/crates/app-server/src/runtime/session_shell.rs index 9007cbb2a..fa32419c7 100644 --- a/lime-rs/crates/app-server/src/runtime/session_shell.rs +++ b/lime-rs/crates/app-server/src/runtime/session_shell.rs @@ -13,16 +13,26 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use tokio_util::sync::CancellationToken; -use tool_runtime::execution_process::{live::LiveExecutionRequest, ExecutionProcessStatus}; +use tool_runtime::execution_process::{ + live::{LiveExecutionOutputQuery, LiveExecutionRequest}, + ExecutionOutputDelta, ExecutionProcessStatus, +}; use uuid::Uuid; const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(25); +const OUTPUT_DRAIN_LIMIT: u16 = 128; +const OUTPUT_DRAIN_MAX_BYTES: u64 = 1024 * 1024; +const COMMAND_OUTPUT_PREVIEW_CHARS: usize = 1_200; #[derive(Debug)] struct ShellProcessOutcome { output: String, exit_code: Option, elapsed_ms: u64, + output_bytes: u64, + output_omitted_bytes: u64, + output_truncated: bool, + process_status: ExecutionProcessStatus, terminal: ShellTerminal, } @@ -222,6 +232,10 @@ impl RuntimeCore { output: error.clone(), exit_code: Some(-1), elapsed_ms: 0, + output_bytes: 0, + output_omitted_bytes: 0, + output_truncated: false, + process_status: ExecutionProcessStatus::Failed, terminal: ShellTerminal::Failed, }; let mut events = vec![ @@ -256,7 +270,17 @@ impl RuntimeCore { )?; let outcome = self - .poll_shell_process(server, process_id, cancellation_token) + .poll_shell_process( + session_id, + thread_id, + turn_id, + item_id, + process_id, + command, + cwd, + server, + cancellation_token, + ) .await; match outcome { Ok(outcome) => { @@ -282,6 +306,10 @@ impl RuntimeCore { output: error.clone(), exit_code: Some(-1), elapsed_ms: 0, + output_bytes: 0, + output_omitted_bytes: 0, + output_truncated: false, + process_status: ExecutionProcessStatus::Failed, terminal: ShellTerminal::Failed, }; let mut events = vec![RuntimeEvent::new( @@ -382,16 +410,47 @@ impl RuntimeCore { async fn poll_shell_process( &self, - server: crate::execution_process::ExecutionProcessServer, + session_id: &str, + thread_id: &str, + turn_id: &str, + item_id: &str, process_id: &str, + command: &str, + cwd: &Path, + server: crate::execution_process::ExecutionProcessServer, cancellation_token: CancellationToken, ) -> Result { let mut canceled = false; + let mut after_sequence = None; loop { if cancellation_token.is_cancelled() && !canceled { canceled = true; let _ = server.terminate(process_id); } + let output = server + .drain_output(LiveExecutionOutputQuery { + process_id: Some(process_id.to_string()), + after_sequence, + limit: Some(OUTPUT_DRAIN_LIMIT), + max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES), + }) + .map_err(|error| error.to_string())?; + after_sequence = output.next_sequence.or(after_sequence); + for delta in output.deltas { + if delta.delta.is_empty() { + continue; + } + self.append_and_publish_shell_events( + session_id, + thread_id, + turn_id, + vec![RuntimeEvent::new( + "command.output", + shell_output_event_payload(item_id, process_id, command, cwd, &delta), + )], + ) + .map_err(|error| error.to_string())?; + } let snapshot = server .status(process_id) .map_err(|error| error.to_string())?; @@ -419,6 +478,10 @@ impl RuntimeCore { output: snapshot.retained_output, exit_code: snapshot.exit_code, elapsed_ms: snapshot.elapsed_ms, + output_bytes: snapshot.output_bytes, + output_omitted_bytes: snapshot.output_omitted_bytes, + output_truncated: snapshot.output_truncated, + process_status: snapshot.status, terminal, }); } @@ -484,11 +547,13 @@ fn shell_event_payload( cwd: &Path, outcome: Option<&ShellProcessOutcome>, ) -> serde_json::Value { - let exit_code = outcome.and_then(|outcome| { - outcome - .exit_code - .or_else(|| (!matches!(outcome.terminal, ShellTerminal::Completed)).then_some(-1)) - }); + let output_ref = shell_output_ref(item_id); + let process_status = outcome + .map(|outcome| outcome.process_status.label()) + .unwrap_or(ExecutionProcessStatus::Running.label()); + let status = outcome + .map(|outcome| shell_terminal_status(outcome.terminal)) + .unwrap_or("running"); json!({ "commandId": item_id, "itemId": item_id, @@ -496,13 +561,79 @@ fn shell_event_payload( "command": command, "cwd": cwd.to_string_lossy(), "output": outcome.map(|outcome| outcome.output.as_str()), - "exitCode": exit_code, + "outputRef": output_ref.clone(), + "refIds": [output_ref], + "exitCode": outcome.and_then(|outcome| outcome.exit_code), + "status": status, "durationMs": outcome.map(|outcome| outcome.elapsed_ms), "source": "user_shell", "metadata": { "commandExecutionSource": "userShell", "processId": process_id, + "executionProcessStatus": process_status, + "executionProcessControlStatus": "registered", + "executionSurface": "user_shell", + "stdinWritable": outcome.is_none(), + "outputBytes": outcome.map(|outcome| outcome.output_bytes), + "outputOmittedBytes": outcome.map(|outcome| outcome.output_omitted_bytes), + "outputTruncated": outcome.map(|outcome| outcome.output_truncated), "durationMs": outcome.map(|outcome| outcome.elapsed_ms), }, }) } + +fn shell_output_event_payload( + item_id: &str, + process_id: &str, + command: &str, + cwd: &Path, + delta: &ExecutionOutputDelta, +) -> serde_json::Value { + let output_ref = shell_output_ref(item_id); + let mut metadata = delta.metadata(); + metadata.insert("commandExecutionSource".to_string(), json!("userShell")); + metadata.insert( + "executionProcessControlStatus".to_string(), + json!("registered"), + ); + metadata.insert("executionSurface".to_string(), json!("user_shell")); + metadata.insert("execution_surface".to_string(), json!("user_shell")); + metadata.insert("command".to_string(), json!(command)); + metadata.insert("cwd".to_string(), json!(cwd.to_string_lossy())); + json!({ + "commandId": item_id, + "itemId": item_id, + "processId": process_id, + "outputRef": output_ref.clone(), + "refIds": [output_ref], + "kind": delta.kind.label(), + "delta": delta.delta, + "preview": truncate_chars(&delta.delta, COMMAND_OUTPUT_PREVIEW_CHARS), + "source": "user_shell_stream", + "metadata": metadata, + }) +} + +fn shell_output_ref(item_id: &str) -> String { + format!("output:user-shell:{item_id}") +} + +fn shell_terminal_status(terminal: ShellTerminal) -> &'static str { + match terminal { + ShellTerminal::Completed => "completed", + ShellTerminal::Failed => "failed", + ShellTerminal::Canceled => "canceled", + } +} + +fn truncate_chars(value: &str, max_chars: usize) -> String { + let mut output = String::new(); + for (index, character) in value.chars().enumerate() { + if index >= max_chars { + output.push('\u{2026}'); + break; + } + output.push(character); + } + output +} diff --git a/lime-rs/crates/app-server/src/runtime/tests/session_shell.rs b/lime-rs/crates/app-server/src/runtime/tests/session_shell.rs index ae9f2400c..7ef417ca5 100644 --- a/lime-rs/crates/app-server/src/runtime/tests/session_shell.rs +++ b/lime-rs/crates/app-server/src/runtime/tests/session_shell.rs @@ -76,6 +76,7 @@ async fn idle_shell_command_persists_a_standalone_turn_and_output() { "turn.accepted", "turn.started", "command.started", + "command.output", "command.exited", "turn.completed", ], @@ -113,6 +114,16 @@ async fn idle_shell_command_persists_a_standalone_turn_and_output() { assert_eq!(*exit_code, Some(0)); assert_eq!(item.metadata["commandExecutionSource"], json!("userShell")); assert!(item.metadata["processId"].as_str().is_some()); + let output_event = events + .iter() + .find(|event| event.event_type == "command.output") + .expect("shell output delta"); + assert_eq!(output_event.payload["commandId"], item.item_id.as_str()); + assert_eq!(output_event.payload["delta"], "shell-loop-ready"); + assert_eq!( + output_event.payload["metadata"]["executionProcessControlStatus"], + "registered" + ); let restarted = RuntimeCore::with_backend(Arc::new(MockBackend)) .with_execution_process_server(ExecutionProcessServer::default()) @@ -180,10 +191,17 @@ async fn active_shell_command_reuses_the_current_turn() { .iter() .filter(|event| event.event_type.starts_with("command.")) .collect::>(); - assert_eq!(command_events.len(), 2); + assert_eq!(command_events.len(), 3); assert!(command_events .iter() .all(|event| event.turn_id.as_deref() == Some("turn-shell-active"))); + assert_eq!( + command_events + .iter() + .map(|event| event.event_type.as_str()) + .collect::>(), + vec!["command.started", "command.output", "command.exited"] + ); let thread = read_full_thread(&core, "thread-shell-active").await; assert_eq!(thread.turns.len(), 1); assert_eq!(thread.turns[0].turn_id.as_str(), "turn-shell-active"); diff --git a/lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs b/lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs index 211c9745e..fc7c02215 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_item_projection/coding_items.rs @@ -178,6 +178,19 @@ fn command_string(payload: &Value) -> Option { } fn command_exit_item_status(payload: &Value) -> &'static str { + if let Some(status) = payload + .get("status") + .and_then(Value::as_str) + .map(|status| status.to_ascii_lowercase()) + { + match status.as_str() { + "canceled" | "cancelled" => return "cancelled", + "interrupted" => return "interrupted", + "failed" | "error" => return "failed", + "completed" | "complete" | "success" | "succeeded" | "passed" => return "completed", + _ => {} + } + } let exit_code = payload .get("exitCode") .or_else(|| payload.get("exit_code")) diff --git a/lime-rs/crates/app-server/src/runtime_backend/coding_events.rs b/lime-rs/crates/app-server/src/runtime_backend/coding_events.rs index 479328eb6..a496110d2 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/coding_events.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/coding_events.rs @@ -43,15 +43,19 @@ impl CodingEventMirror { pub(super) fn process_event(&mut self, event: &RuntimeAgentEvent) -> CodingMirrorEvents { match event { RuntimeAgentEvent::ItemStarted { item } => CodingMirrorEvents { - after_raw: canonical_tool_item(item) - .map(|tool| { - self.handle_tool_start( - tool.name, - tool.call_id, - canonical_arguments_value(tool.arguments).as_ref(), - ) - }) - .unwrap_or_default(), + after_raw: if let Some(command) = canonical_command_item(item) { + self.handle_command_start(item, command) + } else { + canonical_tool_item(item) + .map(|tool| { + self.handle_tool_start( + tool.name, + tool.call_id, + canonical_arguments_value(tool.arguments).as_ref(), + ) + }) + .unwrap_or_default() + }, ..CodingMirrorEvents::default() }, RuntimeAgentEvent::ToolOutputDelta { @@ -68,15 +72,29 @@ impl CodingEventMirror { ), ..CodingMirrorEvents::default() }, + RuntimeAgentEvent::ItemUpdated { item } => { + if let Some(command) = canonical_command_item(item) { + CodingMirrorEvents { + after_raw: self.handle_command_update(item, command), + ..CodingMirrorEvents::default() + } + } else { + CodingMirrorEvents::default() + } + } RuntimeAgentEvent::ItemCompleted { item } => { - let mut events = canonical_tool_item(item) - .and_then(|tool| { - tool.output.map(|output| { - let result = canonical_agent_tool_result(item, output); - self.handle_tool_end(tool.call_id, &result) + let mut events = if let Some(command) = canonical_command_item(item) { + self.handle_command_completed(item, command) + } else { + canonical_tool_item(item) + .and_then(|tool| { + tool.output.map(|output| { + let result = canonical_agent_tool_result(item, output); + self.handle_tool_end(tool.call_id, &result) + }) }) - }) - .unwrap_or_default(); + .unwrap_or_default() + }; if let Some(interaction) = terminal_interaction_event(item) { events.before_raw.push(interaction); } @@ -110,47 +128,14 @@ impl CodingEventMirror { })), )); } - let test_run_id = if is_shell_tool(normalized_name) { - let command = command_facts - .as_ref() - .map(|facts| facts.command.clone()) - .unwrap_or_default(); - events.push(RuntimeEvent::new( - "command.started", - compact_object(json!({ - "commandId": tool_id, - "toolCallId": tool_id, - "toolName": normalized_name, - "command": command, - "canonicalCommand": command_facts.as_ref().map(|facts| facts.canonical_command.clone()), - "commandSummary": command_facts.as_ref().map(|facts| facts.summary.clone()), - "commandArgv": command_facts.as_ref().map(|facts| facts.argv.clone()), - "commandArgvSource": command_facts.as_ref().map(|facts| facts.source), - "cwd": cwd_from_value(arguments_value.as_ref()), - "source": "runtime_tool", - })), - )); - - if is_likely_test_command(&command) { - let test_run_id = stable_scope_id("test", tool_id); - events.push(RuntimeEvent::new( - "test.started", - compact_object(json!({ - "testRunId": test_run_id, - "commandId": tool_id, - "command": command, - "canonicalCommand": command_facts.as_ref().map(|facts| facts.canonical_command.clone()), - "commandSummary": command_facts.as_ref().map(|facts| facts.summary.clone()), - "source": "runtime_tool", - })), - )); - Some(test_run_id) - } else { - None - } - } else { - None - }; + let (shell_events, test_run_id) = self.shell_start_events( + tool_id, + normalized_name, + arguments_value.as_ref(), + command_facts.as_ref(), + "runtime_tool", + ); + events.extend(shell_events); self.tools.insert( tool_id.to_string(), @@ -167,6 +152,147 @@ impl CodingEventMirror { events } + fn shell_start_events( + &self, + tool_id: &str, + tool_name: &str, + arguments: Option<&Value>, + command_facts: Option<&CommandFacts>, + source: &str, + ) -> (Vec, Option) { + if !is_shell_tool(tool_name) { + return (Vec::new(), None); + } + let command = command_facts + .map(|facts| facts.command.clone()) + .or_else(|| command_from_arguments(arguments)) + .unwrap_or_default(); + let mut events = vec![RuntimeEvent::new( + "command.started", + compact_object(json!({ + "commandId": tool_id, + "toolCallId": tool_id, + "toolName": tool_name, + "command": command, + "canonicalCommand": command_facts.map(|facts| facts.canonical_command.clone()), + "commandSummary": command_facts.map(|facts| facts.summary.clone()), + "commandArgv": command_facts.map(|facts| facts.argv.clone()), + "commandArgvSource": command_facts.map(|facts| facts.source), + "cwd": cwd_from_value(arguments), + "source": source, + })), + )]; + + let test_run_id = if is_likely_test_command(&command) { + let test_run_id = stable_scope_id("test", tool_id); + events.push(RuntimeEvent::new( + "test.started", + compact_object(json!({ + "testRunId": test_run_id, + "commandId": tool_id, + "command": command, + "canonicalCommand": command_facts.map(|facts| facts.canonical_command.clone()), + "commandSummary": command_facts.map(|facts| facts.summary.clone()), + "source": source, + })), + )); + Some(test_run_id) + } else { + None + }; + (events, test_run_id) + } + + fn handle_command_start( + &mut self, + item: &ThreadItem, + command: CanonicalCommandItem<'_>, + ) -> Vec { + let command_id = command_item_id(item); + let arguments = command_item_arguments(command); + let command_facts = command_facts_from_text(command.command); + let (events, test_run_id) = self.shell_start_events( + &command_id, + "exec_command", + Some(&arguments), + command_facts.as_ref(), + "runtime_unified_exec", + ); + self.tools.insert( + command_id, + TrackedTool { + name: "exec_command".to_string(), + arguments: Some(arguments), + command_facts, + test_run_id, + patch_id: None, + emitted_output: false, + }, + ); + events + } + + fn handle_command_update( + &mut self, + item: &ThreadItem, + command: CanonicalCommandItem<'_>, + ) -> Vec { + let command_id = command_item_id(item); + if !self.tools.contains_key(&command_id) { + let mut events = self.handle_command_start(item, command); + events.extend(self.handle_command_output(item, command)); + return events; + } + self.handle_command_output(item, command) + } + + fn handle_command_output( + &mut self, + item: &ThreadItem, + command: CanonicalCommandItem<'_>, + ) -> Vec { + let command_id = command_item_id(item); + let metadata = command_item_metadata(item); + let output_kind = metadata + .as_ref() + .and_then(|metadata| metadata_string(metadata, &["outputKind", "output_kind"])); + self.handle_tool_output_delta( + &command_id, + command.output.unwrap_or_default(), + output_kind.as_deref(), + metadata.as_ref(), + ) + } + + fn handle_command_completed( + &mut self, + item: &ThreadItem, + command: CanonicalCommandItem<'_>, + ) -> CodingMirrorEvents { + let command_id = command_item_id(item); + let streamed_output = self + .tools + .get(&command_id) + .is_some_and(|tool| tool.emitted_output); + let mut after_raw = if streamed_output { + Vec::new() + } else if self.tools.contains_key(&command_id) { + self.handle_command_output(item, command) + } else { + let mut events = self.handle_command_start(item, command); + events.extend(self.handle_command_output(item, command)); + events + }; + let result = command_item_result(item, command); + let terminal = self.handle_tool_end(&command_id, &result); + let before_raw = terminal.before_raw; + after_raw.extend(terminal.after_raw); + CodingMirrorEvents { + before_raw, + after_raw, + } + } + fn handle_tool_output_delta( &mut self, tool_id: &str, @@ -211,7 +337,9 @@ impl CodingEventMirror { return CodingMirrorEvents::default(); }; let mut after_raw = match tool.name.as_str() { - "Bash" | "PowerShell" => self.shell_tool_end_events(tool_id, &tool, result), + "Bash" | "PowerShell" | "exec_command" => { + self.shell_tool_end_events(tool_id, &tool, result) + } "Read" => file_read_tool_end_events(tool_id, &tool, result), "Write" | "Edit" | "apply_patch" => file_tool_end_events(tool_id, &tool, result), _ => Vec::new(), @@ -242,7 +370,7 @@ impl CodingEventMirror { let command = command_text.or_else(|| command_facts.as_ref().map(|facts| facts.command.clone())); let exit_code = metadata.and_then(|metadata| metadata_i64(metadata, &["exit_code"])); - let status = command_status(exit_code, result.success); + let status = command_status(exit_code, result.success, metadata); let process_metadata = shell_process_lifecycle_metadata(tool_id, metadata, status); let mut events = Vec::new(); @@ -324,7 +452,7 @@ fn terminal_interaction_event(item: &ThreadItem) -> Option { .as_str() .filter(|value| terminal_interaction_summary_is_safe(value))?; let command_id = match &item.payload { - ThreadItemPayload::Command { .. } => item.item_id.as_str().to_string(), + ThreadItemPayload::Command { .. } => command_item_id(item), ThreadItemPayload::Tool { .. } => { ItemId::new(metadata.get("exec_command_call_id")?.as_str()?.to_string()) .as_str() @@ -361,6 +489,14 @@ struct CanonicalToolItem<'a> { output: Option<&'a ToolOutput>, } +#[derive(Clone, Copy)] +struct CanonicalCommandItem<'a> { + command: &'a str, + cwd: Option<&'a str>, + output: Option<&'a str>, + exit_code: Option, +} + fn canonical_tool_item(item: &ThreadItem) -> Option> { let ThreadItemPayload::Tool { call_id, @@ -379,6 +515,94 @@ fn canonical_tool_item(item: &ThreadItem) -> Option> { }) } +fn canonical_command_item(item: &ThreadItem) -> Option> { + let ThreadItemPayload::Command { + command, + cwd, + output, + exit_code, + } = &item.payload + else { + return None; + }; + Some(CanonicalCommandItem { + command, + cwd: cwd.as_deref(), + output: output.as_deref(), + exit_code: *exit_code, + }) +} + +fn command_item_id(item: &ThreadItem) -> String { + item.metadata + .as_object() + .and_then(|metadata| metadata.get("exec_command_call_id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + item.item_id + .as_str() + .strip_prefix("item_") + .map(ToOwned::to_owned) + }) + .unwrap_or_else(|| item.item_id.as_str().to_string()) +} + +fn command_item_arguments(command: CanonicalCommandItem<'_>) -> Value { + let mut arguments = serde_json::Map::new(); + arguments.insert( + "cmd".to_string(), + Value::String(command.command.to_string()), + ); + if let Some(cwd) = command.cwd { + arguments.insert("workdir".to_string(), Value::String(cwd.to_string())); + } + Value::Object(arguments) +} + +fn command_item_metadata(item: &ThreadItem) -> Option> { + item.metadata.as_object().map(|metadata| { + metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) +} + +fn command_item_result(item: &ThreadItem, command: CanonicalCommandItem<'_>) -> AgentToolResult { + let mut metadata = command_item_metadata(item).unwrap_or_default(); + metadata + .entry("command".to_string()) + .or_insert_with(|| Value::String(command.command.to_string())); + if let Some(cwd) = command.cwd { + metadata + .entry("cwd".to_string()) + .or_insert_with(|| Value::String(cwd.to_string())); + } + if let Some(exit_code) = command.exit_code { + metadata + .entry("exit_code".to_string()) + .or_insert_with(|| Value::from(exit_code)); + } + if matches!(item.status, ItemStatus::Cancelled | ItemStatus::Interrupted) { + metadata + .entry("itemStatus".to_string()) + .or_insert_with(|| Value::String("canceled".to_string())); + metadata + .entry("executionProcessStatus".to_string()) + .or_insert_with(|| Value::String("terminated".to_string())); + } + AgentToolResult { + success: item.status == ItemStatus::Completed, + output: command.output.unwrap_or_default().to_string(), + error: None, + structured_content: None, + images: None, + metadata: (!metadata.is_empty()).then_some(metadata), + } +} + fn canonical_arguments_value(arguments: &[ToolArgument]) -> Option { if arguments.is_empty() { return None; @@ -696,7 +920,7 @@ fn lookup_key(value: &str) -> String { } fn is_shell_tool(tool_name: &str) -> bool { - matches!(tool_name, "Bash" | "PowerShell") + matches!(tool_name, "Bash" | "PowerShell" | "exec_command") } fn command_from_arguments(arguments: Option<&Value>) -> Option { @@ -824,7 +1048,14 @@ fn value_bool(value: &Value) -> Option { }) } -fn command_status(exit_code: Option, success: bool) -> &'static str { +fn command_status( + exit_code: Option, + success: bool, + metadata: Option<&HashMap>, +) -> &'static str { + if metadata_indicates_cancellation(metadata) { + return "canceled"; + } match exit_code { Some(0) => "passed", Some(_) => "failed", @@ -833,6 +1064,34 @@ fn command_status(exit_code: Option, success: bool) -> &'static str { } } +fn metadata_indicates_cancellation(metadata: Option<&HashMap>) -> bool { + if metadata + .and_then(|metadata| metadata_string(metadata, &["tool_outcome", "toolOutcome"])) + .is_some_and(|outcome| outcome.eq_ignore_ascii_case("aborted")) + { + return true; + } + metadata + .and_then(|metadata| { + metadata_string( + metadata, + &[ + "itemStatus", + "item_status", + "executionProcessStatus", + "execution_process_status", + "status", + ], + ) + }) + .is_some_and(|status| { + matches!( + status.to_ascii_lowercase().as_str(), + "canceled" | "cancelled" | "interrupted" | "terminated" + ) + }) +} + fn is_likely_test_command(command: &str) -> bool { let normalized = command.to_ascii_lowercase(); [ diff --git a/lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs b/lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs index f6a7c8f5e..06c38cc16 100644 --- a/lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs +++ b/lime-rs/crates/app-server/src/runtime_backend/coding_events/tests.rs @@ -107,6 +107,223 @@ fn canonical_test_arguments(arguments: Value) -> Vec { } } +fn command_item_event( + call_id: &str, + command: &str, + status: ItemStatus, + output: Option<&str>, + exit_code: Option, + metadata: Value, +) -> RuntimeAgentEvent { + let payload = ThreadItemPayload::Command { + command: command.to_string(), + cwd: Some("/workspace".to_string()), + output: output.map(str::to_string), + exit_code, + }; + let item = ThreadItem { + session_id: SessionId::new("session-test"), + thread_id: ThreadId::new("thread-test"), + turn_id: TurnId::new("turn-test"), + item_id: ItemId::new(call_id), + sequence: 1, + ordinal: 1, + created_at_ms: 1, + updated_at_ms: 2, + completed_at_ms: status.is_terminal().then_some(2), + kind: payload.kind(), + status, + payload, + metadata, + }; + if status.is_terminal() { + RuntimeAgentEvent::ItemCompleted { item } + } else { + RuntimeAgentEvent::ItemUpdated { item } + } +} + +fn command_item_started(call_id: &str, command: &str) -> RuntimeAgentEvent { + let payload = ThreadItemPayload::Command { + command: command.to_string(), + cwd: Some("/workspace".to_string()), + output: None, + exit_code: None, + }; + RuntimeAgentEvent::ItemStarted { + item: ThreadItem { + session_id: SessionId::new("session-test"), + thread_id: ThreadId::new("thread-test"), + turn_id: TurnId::new("turn-test"), + item_id: ItemId::new(call_id), + sequence: 1, + ordinal: 1, + created_at_ms: 1, + updated_at_ms: 2, + completed_at_ms: None, + kind: payload.kind(), + status: ItemStatus::InProgress, + payload, + metadata: json!({}), + }, + } +} + +#[test] +fn unified_exec_command_items_emit_command_and_test_lifecycle() { + let mut mirror = CodingEventMirror::default(); + let command = "cargo test -p app-server coding_events"; + + let started = mirror.process_event(&command_item_started("exec-call", command)); + let updated = mirror.process_event(&command_item_event( + "exec-call", + command, + ItemStatus::InProgress, + Some("running tests\n"), + None, + json!({ + "exec_command_call_id": "exec-call", + "processId": "unified-exec-1", + "executionProcessStatus": "running", + "executionSurface": "unified_exec", + "outputBytes": 14, + "outputOmittedBytes": 0, + "outputTruncated": false, + "stdinWritable": true + }), + )); + let completed = mirror.process_event(&command_item_event( + "exec-call", + command, + ItemStatus::Completed, + Some("all tests passed\n"), + Some(0), + json!({ + "exec_command_call_id": "exec-call", + "processId": "unified-exec-1", + "executionProcessStatus": "exited", + "executionSurface": "unified_exec", + "outputBytes": 31, + "outputOmittedBytes": 0, + "outputTruncated": false, + "stdinWritable": false, + "exit_code": 0 + }), + )); + + let event_types = started + .after_raw + .iter() + .chain(updated.after_raw.iter()) + .chain(completed.after_raw.iter()) + .map(|event| event.event_type.clone()) + .collect::>(); + assert_eq!( + event_types, + vec![ + "command.started", + "test.started", + "command.output", + "command.exited", + "test.completed" + ] + ); + assert_eq!( + completed + .after_raw + .iter() + .find(|event| event.event_type == "command.exited") + .expect("command.exited") + .payload["processId"], + "unified-exec-1" + ); + for event in started + .after_raw + .iter() + .chain(updated.after_raw.iter()) + .chain(completed.after_raw.iter()) + .filter(|event| event.event_type.starts_with("command.")) + { + assert_eq!(event.payload["commandId"], "exec-call"); + } +} + +#[test] +fn completed_command_item_without_started_event_reconstructs_lifecycle() { + let mut mirror = CodingEventMirror::default(); + let completed = mirror.process_event(&command_item_event( + "exec-call-cold", + "printf done", + ItemStatus::Completed, + Some("done\n"), + Some(0), + json!({ + "exec_command_call_id": "exec-call-cold", + "processId": "unified-exec-cold", + "executionProcessStatus": "exited", + "executionSurface": "unified_exec", + "outputBytes": 5, + "outputOmittedBytes": 0, + "outputTruncated": false, + "stdinWritable": false + }), + )); + + let event_types = completed + .after_raw + .iter() + .map(|event| event.event_type.clone()) + .collect::>(); + assert_eq!( + event_types, + vec!["command.started", "command.output", "command.exited"] + ); + assert!(completed + .after_raw + .iter() + .all(|event| event.payload["commandId"] == "exec-call-cold")); +} + +#[test] +fn canceled_command_item_projects_canceled_test_result() { + let mut mirror = CodingEventMirror::default(); + let _ = mirror.process_event(&command_item_started( + "exec-call-canceled", + "cargo test -p app-server", + )); + let canceled = mirror.process_event(&command_item_event( + "exec-call-canceled", + "cargo test -p app-server", + ItemStatus::Cancelled, + Some("interrupted\n"), + None, + json!({ + "exec_command_call_id": "exec-call-canceled", + "processId": "unified-exec-canceled", + "executionProcessStatus": "terminated", + "executionSurface": "unified_exec", + "tool_outcome": "aborted", + "stdinWritable": false + }), + )); + + let exited = canceled + .after_raw + .iter() + .find(|event| event.event_type == "command.exited") + .expect("command.exited"); + assert_eq!(exited.payload["status"], "canceled"); + assert_eq!( + canceled + .after_raw + .iter() + .find(|event| event.event_type == "test.completed") + .expect("test.completed") + .payload["result"], + "canceled" + ); +} + #[test] fn shell_tool_events_emit_command_and_test_lifecycle() { let mut mirror = CodingEventMirror::default(); @@ -235,7 +452,7 @@ fn canonical_command_completion_emits_redacted_terminal_interaction_before_raw_i assert_eq!(output.before_raw.len(), 1); assert!(output.after_raw.is_empty()); assert_eq!(output.before_raw[0].event_type, "command.interaction"); - assert_eq!(output.before_raw[0].payload["commandId"], "item_exec-call"); + assert_eq!(output.before_raw[0].payload["commandId"], "exec-call"); assert_eq!(output.before_raw[0].payload["processId"], "process-7"); assert_eq!(output.before_raw[0].payload["stdin"], "sent 9 chars"); assert!(!output.before_raw[0] diff --git a/lime-rs/crates/cli/src/debug_sandbox.rs b/lime-rs/crates/cli/src/debug_sandbox.rs index 0a7ee1927..aa6fb1df9 100644 --- a/lime-rs/crates/cli/src/debug_sandbox.rs +++ b/lime-rs/crates/cli/src/debug_sandbox.rs @@ -4,7 +4,7 @@ use std::path::PathBuf; use std::process::ExitCode; use anyhow::{bail, Context, Result}; -use app_server_client::{ClientSession, SessionEvent, StdioTransportConfig}; +use app_server_client::{AppServerEvent, ClientSession, StdioTransportConfig}; use app_server_protocol::protocol::v2::{ CommandExecOutputStream, CommandExecParams, CommandExecResponse, CommandExecWriteParams, ServerNotification, ServerRequest, METHOD_COMMAND_EXEC, METHOD_COMMAND_EXEC_WRITE, @@ -208,7 +208,7 @@ async fn run_with_inherited_stdio( return response.context("App Server command/exec failed"); } event = session.next_event() => match event { - Some(SessionEvent::Notification(notification)) => { + Some(AppServerEvent::ServerNotification(notification)) => { if let ServerNotification::CommandExecOutputDelta(delta) = *notification { if delta.process_id != process_id { continue; @@ -226,25 +226,13 @@ async fn run_with_inherited_stdio( } } } - Some(SessionEvent::ServerRequest(request)) => { + Some(AppServerEvent::ServerRequest(request)) => { reject_unexpected_server_request(&request_handle, &request).await?; } - Some(SessionEvent::RawServerRequest(request)) => { - request_handle - .reject( - request.id, - JsonRpcError::new( - error_codes::METHOD_NOT_FOUND, - "lime sandbox does not support server requests", - ), - ) - .await - .context("failed to reject unexpected App Server request")?; - } - Some(SessionEvent::Disconnected { message }) => { + Some(AppServerEvent::Disconnected { message }) => { bail!("App Server disconnected during sandbox execution: {message}"); } - Some(SessionEvent::RawNotification(_)) => {} + Some(AppServerEvent::Lagged { .. }) => {} None => bail!("App Server event stream closed during sandbox execution"), }, read = stdin.read(&mut input), if stdin_open => { diff --git a/lime-rs/crates/cli/src/sandbox_setup.rs b/lime-rs/crates/cli/src/sandbox_setup.rs index 331f880c8..82c4268ad 100644 --- a/lime-rs/crates/cli/src/sandbox_setup.rs +++ b/lime-rs/crates/cli/src/sandbox_setup.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::{bail, Context}; #[cfg(windows)] -use app_server_client::SessionEvent; +use app_server_client::AppServerEvent; #[cfg(windows)] use app_server_protocol::protocol::v2::{ ServerNotification, WindowsSandboxSetupMode, WindowsSandboxSetupStartParams, @@ -129,7 +129,7 @@ async fn run_elevated( Ok(response) if response.started => tokio::time::timeout(Duration::from_secs(130), async { loop { match session.next_event().await { - Some(SessionEvent::Notification(notification)) => match *notification { + Some(AppServerEvent::ServerNotification(notification)) => match *notification { ServerNotification::WindowsSandboxSetupCompleted(completed) if completed.mode == mode => { @@ -137,7 +137,7 @@ async fn run_elevated( } _ => {} }, - Some(SessionEvent::Disconnected { message }) => { + Some(AppServerEvent::Disconnected { message }) => { break Err(anyhow::anyhow!( "App Server disconnected during Windows sandbox setup: {message}" )); diff --git a/lime-rs/crates/tool-runtime/src/execution_process.rs b/lime-rs/crates/tool-runtime/src/execution_process.rs index cd8f12a3a..236e453bd 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process.rs @@ -4,18 +4,19 @@ use crate::sandbox::{ use app_server_protocol::protocol::v2::GrantedPermissionProfile; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; -use std::collections::{HashMap, VecDeque}; +use std::collections::HashMap; use std::io; use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::process::{Child, ChildStdin, Command}; -use tokio::sync::{mpsc, oneshot, watch, Mutex}; +use tokio::sync::{broadcast, mpsc, oneshot, watch, Mutex}; use tokio::task::JoinHandle; mod environment; pub mod live; +mod output_buffer; mod pty; #[cfg(target_os = "windows")] mod windows; @@ -107,9 +108,11 @@ pub fn audit_windows_world_writable( } use environment::resolve_child_environment; +pub use output_buffer::{BoundedProcessOutput, BoundedProcessOutputSnapshot, ProcessOutputFramers}; -const DEFAULT_OUTPUT_RETAIN_BYTES: usize = 128 * 1024; +pub const DEFAULT_OUTPUT_RETAIN_BYTES: usize = 1024 * 1024; const PROCESS_OUTPUT_CHUNK_BYTES: usize = 8 * 1024; +const PROCESS_OUTPUT_CHANNEL_CAPACITY: usize = 64; pub(crate) const TRAILING_OUTPUT_GRACE: Duration = Duration::from_millis(100); #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -285,6 +288,7 @@ pub struct ExecutionProcess { failure: Option, started_at: Instant, output: BoundedProcessOutput, + output_framers: ProcessOutputFramers, sequence: u64, } @@ -301,6 +305,7 @@ impl ExecutionProcess { failure: None, started_at: Instant::now(), output: BoundedProcessOutput::new(DEFAULT_OUTPUT_RETAIN_BYTES), + output_framers: ProcessOutputFramers::default(), sequence: 0, } } @@ -321,21 +326,46 @@ impl ExecutionProcess { &mut self, kind: ExecutionOutputKind, bytes: &[u8], - ) -> ExecutionOutputDelta { - self.sequence = self.sequence.saturating_add(1); + ) -> Option { self.output.push(bytes); + let frame = self.output_framers.push(kind, bytes); + self.output_delta(kind, frame) + } + + pub fn finish_output(&mut self, kind: ExecutionOutputKind) -> Option { + let frame = self.output_framers.finish(kind); + self.output_delta(kind, frame) + } + + pub fn finish_all_output(&mut self) -> Vec { + let frames = self.output_framers.finish_all(); + frames + .into_iter() + .filter_map(|(kind, frame)| self.output_delta(kind, frame)) + .collect() + } + + fn output_delta( + &mut self, + kind: ExecutionOutputKind, + frame: Vec, + ) -> Option { + if frame.is_empty() { + return None; + } + self.sequence = self.sequence.saturating_add(1); let snapshot = self.output.snapshot(); - ExecutionOutputDelta { + Some(ExecutionOutputDelta { process_id: self.process_id.clone(), tool_id: self.tool_id.clone(), sequence: self.sequence, kind, - delta: String::from_utf8_lossy(bytes).to_string(), + delta: String::from_utf8_lossy(&frame).into_owned(), bytes: snapshot.bytes, omitted_bytes: snapshot.omitted_bytes, truncated: snapshot.truncated, - raw_bytes: bytes.to_vec(), - } + raw_bytes: frame, + }) } pub fn interrupt(&mut self) { @@ -404,7 +434,7 @@ impl ExecutionProcessManager { ) -> Option { self.processes .get_mut(process_id) - .map(|process| process.append_output(kind, bytes)) + .and_then(|process| process.append_output(kind, bytes)) } pub fn interrupt(&mut self, process_id: &str) -> Option { @@ -518,7 +548,7 @@ pub trait LiveExecutionProcessRegistry: Send + Sync { pub struct LocalExecutionProcessHandle { process_id: String, control_tx: LocalExecutionControlSender, - output_rx: mpsc::UnboundedReceiver, + output_rx: broadcast::Receiver, state_rx: watch::Receiver, final_rx: Option>, final_snapshot: Option, @@ -555,36 +585,55 @@ impl LocalExecutionProcessHandle { } pub async fn recv_output(&mut self) -> Option { - self.output_rx.recv().await + loop { + match self.output_rx.recv().await { + Ok(delta) => return Some(delta), + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => return None, + } + } } pub async fn next_event(&mut self) -> Option { - if let Ok(delta) = self.output_rx.try_recv() { - return Some(LocalExecutionProcessEvent::Output(delta)); - } - if self.final_snapshot.is_some() { - return None; - } - let mut final_rx = self.final_rx.take()?; - tokio::select! { - biased; - delta = self.output_rx.recv() => { - match delta { - Some(delta) => { - self.final_rx = Some(final_rx); - return Some(LocalExecutionProcessEvent::Output(delta)); - } - None => { - let snapshot = final_rx.await.ok()?; - self.final_snapshot = Some(snapshot.clone()); - return Some(LocalExecutionProcessEvent::Exited(snapshot)); - } + loop { + loop { + match self.output_rx.try_recv() { + Ok(delta) => return Some(LocalExecutionProcessEvent::Output(delta)), + Err(broadcast::error::TryRecvError::Lagged(_)) => continue, + Err( + broadcast::error::TryRecvError::Empty + | broadcast::error::TryRecvError::Closed, + ) => break, } } - result = &mut final_rx => { - let snapshot = result.ok()?; - self.final_snapshot = Some(snapshot.clone()); - return Some(LocalExecutionProcessEvent::Exited(snapshot)); + if self.final_snapshot.is_some() { + return None; + } + let mut final_rx = self.final_rx.take()?; + tokio::select! { + biased; + delta = self.output_rx.recv() => { + match delta { + Ok(delta) => { + self.final_rx = Some(final_rx); + return Some(LocalExecutionProcessEvent::Output(delta)); + } + Err(broadcast::error::RecvError::Lagged(_)) => { + self.final_rx = Some(final_rx); + continue; + } + Err(broadcast::error::RecvError::Closed) => { + let snapshot = final_rx.await.ok()?; + self.final_snapshot = Some(snapshot.clone()); + return Some(LocalExecutionProcessEvent::Exited(snapshot)); + } + } + } + result = &mut final_rx => { + let snapshot = result.ok()?; + self.final_snapshot = Some(snapshot.clone()); + return Some(LocalExecutionProcessEvent::Exited(snapshot)); + } } } } @@ -787,7 +836,7 @@ fn start_local_execution_process_with_inherited_environment( let process_state = ExecutionProcess::start(start); let initial_snapshot = process_state.snapshot(); let process = Arc::new(Mutex::new(process_state)); - let (output_tx, output_rx) = mpsc::unbounded_channel(); + let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY); let (control_tx, control_rx) = mpsc::unbounded_channel(); let (state_tx, state_rx) = watch::channel(initial_snapshot); let (final_tx, final_rx) = oneshot::channel(); @@ -813,7 +862,7 @@ async fn supervise_local_process( stdout: Option, stderr: Option, process: Arc>, - output_tx: mpsc::UnboundedSender, + output_tx: broadcast::Sender, state_tx: watch::Sender, final_tx: oneshot::Sender, mut control_rx: mpsc::UnboundedReceiver, @@ -833,7 +882,7 @@ async fn supervise_local_process( reader, ExecutionOutputKind::Stderr, Arc::clone(&process), - output_tx, + output_tx.clone(), state_tx.clone(), )) }); @@ -886,7 +935,13 @@ async fn supervise_local_process( join_output_tasks_with_grace(stdout_task, stderr_task).await; - let final_snapshot = process.lock().await.snapshot(); + let (final_deltas, final_snapshot) = { + let mut guard = process.lock().await; + (guard.finish_all_output(), guard.snapshot()) + }; + for delta in final_deltas { + let _ = output_tx.send(delta); + } let _ = state_tx.send(final_snapshot.clone()); let _ = final_tx.send(final_snapshot); } @@ -946,7 +1001,7 @@ async fn read_process_stream( mut reader: R, kind: ExecutionOutputKind, process: Arc>, - output_tx: mpsc::UnboundedSender, + output_tx: broadcast::Sender, state_tx: watch::Sender, ) where R: AsyncRead + Unpin + Send + 'static, @@ -964,86 +1019,15 @@ async fn read_process_stream( let snapshot = guard.snapshot(); (delta, snapshot) }; - let _ = output_tx.send(delta); + if let Some(delta) = delta { + let _ = output_tx.send(delta); + } let _ = state_tx.send(snapshot); } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BoundedProcessOutput { - retain_bytes: usize, - bytes: u64, - retained_bytes: usize, - omitted_bytes: u64, - chunks: VecDeque>, -} - -impl BoundedProcessOutput { - fn new(retain_bytes: usize) -> Self { - Self { - retain_bytes, - bytes: 0, - retained_bytes: 0, - omitted_bytes: 0, - chunks: VecDeque::new(), - } + let final_delta = process.lock().await.finish_output(kind); + if let Some(delta) = final_delta { + let _ = output_tx.send(delta); } - - fn push(&mut self, bytes: &[u8]) { - self.bytes = self.bytes.saturating_add(bytes.len() as u64); - if self.retain_bytes == 0 { - self.omitted_bytes = self.omitted_bytes.saturating_add(bytes.len() as u64); - return; - } - - let chunk = if bytes.len() > self.retain_bytes { - let omitted = bytes.len() - self.retain_bytes; - self.omitted_bytes = self.omitted_bytes.saturating_add(omitted as u64); - bytes[omitted..].to_vec() - } else { - bytes.to_vec() - }; - self.retained_bytes += chunk.len(); - self.chunks.push_back(chunk); - - while self.retained_bytes > self.retain_bytes { - let overflow = self.retained_bytes - self.retain_bytes; - let Some(front) = self.chunks.front_mut() else { - break; - }; - if front.len() <= overflow { - let removed = self.chunks.pop_front().unwrap_or_default(); - self.retained_bytes -= removed.len(); - self.omitted_bytes = self.omitted_bytes.saturating_add(removed.len() as u64); - } else { - front.drain(..overflow); - self.retained_bytes -= overflow; - self.omitted_bytes = self.omitted_bytes.saturating_add(overflow as u64); - } - } - } - - fn snapshot(&self) -> BoundedProcessOutputSnapshot { - let retained = self - .chunks - .iter() - .flat_map(|chunk| chunk.iter().copied()) - .collect::>(); - BoundedProcessOutputSnapshot { - bytes: self.bytes, - omitted_bytes: self.omitted_bytes, - truncated: self.omitted_bytes > 0, - text: String::from_utf8_lossy(&retained).to_string(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct BoundedProcessOutputSnapshot { - bytes: u64, - omitted_bytes: u64, - truncated: bool, - text: String, } fn duration_millis(duration: Duration) -> u64 { diff --git a/lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs b/lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs new file mode 100644 index 000000000..e96a7afbf --- /dev/null +++ b/lime-rs/crates/tool-runtime/src/execution_process/output_buffer.rs @@ -0,0 +1,286 @@ +use std::collections::VecDeque; + +use super::ExecutionOutputKind; + +/// Bounded process output that preserves the first and last bytes. +/// +/// Keeping both ends matches the unified exec contract: command headers and +/// final diagnostics remain available while the middle is explicitly counted +/// as omitted. The buffer stores bytes only and never grows with process +/// output after the configured retention limit is reached. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoundedProcessOutput { + retain_bytes: usize, + head: Vec, + tail: VecDeque, + bytes: u64, + omitted_bytes: u64, +} + +impl Default for BoundedProcessOutput { + fn default() -> Self { + Self::new(super::DEFAULT_OUTPUT_RETAIN_BYTES) + } +} + +impl BoundedProcessOutput { + pub fn new(retain_bytes: usize) -> Self { + Self { + retain_bytes, + head: Vec::new(), + tail: VecDeque::new(), + bytes: 0, + omitted_bytes: 0, + } + } + + /// Append raw output while retaining only the configured head/tail window. + pub fn push(&mut self, bytes: &[u8]) { + self.bytes = self.bytes.saturating_add(bytes.len() as u64); + let remaining_head = self.head_budget().saturating_sub(self.head.len()); + let (head, tail) = bytes + .split_at_checked(remaining_head) + .unwrap_or((bytes, &[])); + self.head.extend_from_slice(head); + self.push_tail(tail); + } + + pub fn snapshot(&self) -> BoundedProcessOutputSnapshot { + let retained_bytes = self.head.len().saturating_add(self.tail.len()); + let marker = (self.omitted_bytes > 0) + .then(|| format!("... {} bytes omitted ...", self.omitted_bytes)); + let marker_bytes = marker + .as_ref() + .map(|marker| marker.len().saturating_add(2)) + .unwrap_or_default(); + let mut retained = Vec::with_capacity(retained_bytes.saturating_add(marker_bytes)); + retained.extend_from_slice(&self.head); + if let Some(marker) = marker { + retained.push(b'\n'); + retained.extend_from_slice(marker.as_bytes()); + retained.push(b'\n'); + } + retained.extend(self.tail.iter().copied()); + BoundedProcessOutputSnapshot { + bytes: self.bytes, + omitted_bytes: self.omitted_bytes, + truncated: self.omitted_bytes > 0, + text: String::from_utf8_lossy(&retained).into_owned(), + } + } + + pub fn is_empty(&self) -> bool { + self.bytes == 0 + } + + fn head_budget(&self) -> usize { + self.retain_bytes / 2 + } + + fn tail_budget(&self) -> usize { + self.retain_bytes.saturating_sub(self.head_budget()) + } + + fn push_tail(&mut self, bytes: &[u8]) { + let remaining_tail = self.tail_budget().saturating_sub(self.tail.len()); + let excess = bytes.len().saturating_sub(remaining_tail); + self.omitted_bytes = self.omitted_bytes.saturating_add(excess as u64); + + // Drop old tail bytes before skipping incoming bytes. This keeps the + // newest suffix when one chunk is larger than the entire tail budget. + let bytes = match excess.checked_sub(self.tail.len()) { + None => { + self.tail.drain(..excess); + bytes + } + Some(skip) => { + self.tail.clear(); + &bytes[skip..] + } + }; + self.tail.extend(bytes); + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BoundedProcessOutputSnapshot { + pub bytes: u64, + pub omitted_bytes: u64, + pub truncated: bool, + pub text: String, +} + +/// Frames process output on UTF-8 scalar boundaries without changing raw bytes. +/// +/// A scalar spans at most four bytes, so each stream retains at most three +/// incomplete bytes between producer chunks. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ProcessOutputFramers { + stdout: Utf8OutputFramer, + stderr: Utf8OutputFramer, + combined: Utf8OutputFramer, +} + +impl ProcessOutputFramers { + pub fn push(&mut self, kind: ExecutionOutputKind, bytes: &[u8]) -> Vec { + self.framer(kind).push(bytes) + } + + pub fn finish(&mut self, kind: ExecutionOutputKind) -> Vec { + self.framer(kind).finish() + } + + pub fn finish_all(&mut self) -> Vec<(ExecutionOutputKind, Vec)> { + [ + ExecutionOutputKind::Stdout, + ExecutionOutputKind::Stderr, + ExecutionOutputKind::Combined, + ] + .into_iter() + .filter_map(|kind| { + let bytes = self.finish(kind); + (!bytes.is_empty()).then_some((kind, bytes)) + }) + .collect() + } + + fn framer(&mut self, kind: ExecutionOutputKind) -> &mut Utf8OutputFramer { + match kind { + ExecutionOutputKind::Stdout => &mut self.stdout, + ExecutionOutputKind::Stderr => &mut self.stderr, + ExecutionOutputKind::Combined => &mut self.combined, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct Utf8OutputFramer { + pending: Vec, +} + +impl Utf8OutputFramer { + fn push(&mut self, bytes: &[u8]) -> Vec { + let mut frame = std::mem::take(&mut self.pending); + frame.extend_from_slice(bytes); + let boundary = utf8_boundary(&frame); + self.pending = frame.split_off(boundary); + frame + } + + fn finish(&mut self) -> Vec { + std::mem::take(&mut self.pending) + } +} + +/// Keep only a potentially incomplete UTF-8 suffix; malformed bytes remain raw. +fn utf8_boundary(bytes: &[u8]) -> usize { + let mut boundary = bytes.len().saturating_sub(char::MAX.len_utf8() - 1); + while boundary < bytes.len() { + match std::str::from_utf8(&bytes[boundary..]) { + Ok(value) => return boundary + value.len(), + Err(error) => { + boundary += error.valid_up_to(); + if let Some(invalid_len) = error.error_len() { + boundary += invalid_len; + } else { + return boundary; + } + } + } + } + bytes.len() +} + +#[cfg(test)] +mod tests { + use super::{BoundedProcessOutput, ProcessOutputFramers}; + use crate::execution_process::ExecutionOutputKind; + + #[test] + fn preserves_head_and_tail_when_output_exceeds_budget() { + let mut output = BoundedProcessOutput::new(10); + output.push(b"0123456789"); + output.push(b"ab"); + + let snapshot = output.snapshot(); + assert_eq!(snapshot.bytes, 12); + assert_eq!(snapshot.omitted_bytes, 2); + assert!(snapshot.truncated); + assert_eq!(snapshot.text, "01234\n... 2 bytes omitted ...\n789ab"); + } + + #[test] + fn zero_budget_drops_every_byte_without_growing() { + let mut output = BoundedProcessOutput::new(0); + output.push(b"abc"); + + let snapshot = output.snapshot(); + assert_eq!(snapshot.bytes, 3); + assert_eq!(snapshot.omitted_bytes, 3); + assert_eq!(snapshot.text, "\n... 3 bytes omitted ...\n"); + } + + #[test] + fn a_single_byte_budget_keeps_the_latest_byte() { + let mut output = BoundedProcessOutput::new(1); + output.push(b"abc"); + + let snapshot = output.snapshot(); + assert_eq!(snapshot.omitted_bytes, 2); + assert_eq!(snapshot.text, "\n... 2 bytes omitted ...\nc"); + } + + #[test] + fn converts_utf8_after_joining_retained_bytes() { + let mut output = BoundedProcessOutput::new(8); + output.push(&[b'a', b'b', 0xf0, 0x9f]); + output.push(&[0x98, 0x80, b'c', b'd']); + + let snapshot = output.snapshot(); + assert_eq!(snapshot.bytes, 8); + assert_eq!(snapshot.omitted_bytes, 0); + assert_eq!(snapshot.text, "ab\u{1f600}cd"); + } + + #[test] + fn frames_multibyte_characters_across_chunks() { + let mut framers = ProcessOutputFramers::default(); + + assert!(framers + .push(ExecutionOutputKind::Stdout, &[0xf0, 0x9f]) + .is_empty()); + assert_eq!( + framers.push(ExecutionOutputKind::Stdout, &[0x98, 0x80]), + "\u{1f600}".as_bytes() + ); + } + + #[test] + fn frames_streams_independently_and_flushes_incomplete_suffixes() { + let mut framers = ProcessOutputFramers::default(); + + assert!(framers + .push(ExecutionOutputKind::Stdout, &[0xc3]) + .is_empty()); + assert_eq!( + framers.push(ExecutionOutputKind::Stderr, b"error"), + b"error" + ); + assert_eq!(framers.finish(ExecutionOutputKind::Stdout), vec![0xc3]); + assert!(framers.finish(ExecutionOutputKind::Stderr).is_empty()); + } + + #[test] + fn malformed_bytes_do_not_block_later_valid_output() { + let mut framers = ProcessOutputFramers::default(); + + assert_eq!( + framers.push(ExecutionOutputKind::Combined, &[0xff, 0xc3]), + vec![0xff] + ); + assert_eq!( + framers.push(ExecutionOutputKind::Combined, &[0xa9, b'!']), + ["\u{e9}".as_bytes(), b"!"].concat() + ); + } +} diff --git a/lime-rs/crates/tool-runtime/src/execution_process/pty.rs b/lime-rs/crates/tool-runtime/src/execution_process/pty.rs index bffa42d0b..9681cf5ec 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/pty.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/pty.rs @@ -56,7 +56,7 @@ pub(super) fn start_local_pty_execution_process( let process_state = ExecutionProcess::start(start); let initial_snapshot = process_state.snapshot(); let process = Arc::new(Mutex::new(process_state)); - let (output_tx, output_rx) = mpsc::unbounded_channel(); + let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY); let (control_tx, control_rx) = std::sync::mpsc::channel(); let (state_tx, state_rx) = watch::channel(initial_snapshot); let (final_tx, final_rx) = oneshot::channel(); @@ -91,7 +91,7 @@ pub(super) fn start_local_pty_execution_process( fn read_pty_process_stream( mut reader: Box, process: Arc>, - output_tx: mpsc::UnboundedSender, + output_tx: broadcast::Sender, state_tx: watch::Sender, ) { let mut buffer = vec![0; PROCESS_OUTPUT_CHUNK_BYTES]; @@ -108,9 +108,17 @@ fn read_pty_process_stream( let snapshot = guard.snapshot(); (delta, snapshot) }; - let _ = output_tx.send(delta); + if let Some(delta) = delta { + let _ = output_tx.send(delta); + } let _ = state_tx.send(snapshot); } + let final_delta = process + .blocking_lock() + .finish_output(ExecutionOutputKind::Combined); + if let Some(delta) = final_delta { + let _ = output_tx.send(delta); + } } #[allow(clippy::too_many_arguments)] diff --git a/lime-rs/crates/tool-runtime/src/execution_process/tests.rs b/lime-rs/crates/tool-runtime/src/execution_process/tests.rs index 4ec5b018d..414d6dce3 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/tests.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/tests.rs @@ -15,7 +15,9 @@ fn start_process() -> ExecutionProcess { #[test] fn process_tracks_output_delta_metadata() { let mut process = start_process(); - let delta = process.append_output(ExecutionOutputKind::Stdout, b"hello"); + let delta = process + .append_output(ExecutionOutputKind::Stdout, b"hello") + .expect("complete output should emit a delta"); assert_eq!(delta.sequence, 1); assert_eq!(delta.delta, "hello"); @@ -31,6 +33,40 @@ fn process_tracks_output_delta_metadata() { assert_eq!(metadata.get("stdin_writable"), Some(&json!(true))); } +#[test] +fn process_frames_utf8_deltas_across_reader_chunks() { + let mut process = start_process(); + + assert!(process + .append_output(ExecutionOutputKind::Stdout, &[0xf0, 0x9f]) + .is_none()); + let delta = process + .append_output(ExecutionOutputKind::Stdout, &[0x98, 0x80]) + .expect("completed scalar should emit"); + + assert_eq!(delta.delta, "\u{1f600}"); + assert_eq!(delta.raw_bytes, "\u{1f600}".as_bytes()); + assert_eq!(delta.bytes, 4); + assert_eq!(process.snapshot().retained_output, "\u{1f600}"); +} + +#[test] +fn process_flushes_incomplete_utf8_suffix_at_stream_end() { + let mut process = start_process(); + + assert!(process + .append_output(ExecutionOutputKind::Stderr, &[0xc3]) + .is_none()); + let delta = process + .finish_output(ExecutionOutputKind::Stderr) + .expect("incomplete suffix should flush"); + + assert_eq!(delta.delta, "\u{fffd}"); + assert_eq!(delta.raw_bytes, vec![0xc3]); + assert_eq!(delta.bytes, 1); + assert_eq!(process.snapshot().retained_output, "\u{fffd}"); +} + #[test] fn process_bounds_retained_output() { let mut output = BoundedProcessOutput::new(8); @@ -41,7 +77,7 @@ fn process_bounds_retained_output() { assert_eq!(snapshot.bytes, 10); assert_eq!(snapshot.omitted_bytes, 2); assert!(snapshot.truncated); - assert_eq!(snapshot.text, "34567890"); + assert_eq!(snapshot.text, "1234\n... 2 bytes omitted ...\n7890"); } #[test] @@ -124,10 +160,10 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() { command }; let child = command.spawn().expect("direct child should start"); - let (_stdout_writer, stdout_reader) = tokio::io::duplex(64); + let (mut stdout_writer, stdout_reader) = tokio::io::duplex(64); let (_stderr_writer, stderr_reader) = tokio::io::duplex(64); let process = Arc::new(Mutex::new(start_process())); - let (output_tx, _output_rx) = mpsc::unbounded_channel(); + let (output_tx, mut output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY); let (control_tx, control_rx) = mpsc::unbounded_channel(); let (state_tx, mut state_rx) = watch::channel(process.lock().await.snapshot()); let (final_tx, _final_rx) = oneshot::channel(); @@ -143,6 +179,10 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() { final_tx, control_rx, )); + stdout_writer + .write_all(&[0xc3]) + .await + .expect("write incomplete UTF-8 prefix"); let terminal_snapshot = tokio::time::timeout(Duration::from_secs(1), async { loop { @@ -161,6 +201,12 @@ async fn local_process_reports_direct_child_exit_when_output_pipes_stay_open() { assert_eq!(terminal_snapshot.status, ExecutionProcessStatus::Exited); assert_eq!(terminal_snapshot.exit_code, Some(7)); + let flushed = tokio::time::timeout(Duration::from_secs(1), output_rx.recv()) + .await + .expect("grace fallback should flush pending output") + .expect("output channel should remain available until final flush"); + assert_eq!(flushed.delta, "\u{fffd}"); + assert_eq!(flushed.raw_bytes, vec![0xc3]); drop(control_tx); } @@ -191,6 +237,34 @@ async fn local_process_does_not_inherit_sensitive_parent_environment() { assert_eq!(final_snapshot.retained_output, "filtered"); } +#[cfg(unix)] +#[tokio::test] +async fn local_process_large_output_completes_without_draining_deltas() { + let output_bytes = DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024; + let mut handle = start_local_execution_process(LocalExecutionRequest::new( + "process-local-large-output", + "tool-local-large-output", + "exec_command", + shell_command(&format!( + "awk 'BEGIN {{ for (i = 0; i < {output_bytes}; i++) printf \"x\" }}'" + )), + )) + .expect("large-output process should start"); + + let snapshot = tokio::time::timeout(Duration::from_secs(10), handle.wait()) + .await + .expect("large-output process should not block on an undrained delta receiver") + .expect("large-output process should finish"); + + assert_eq!(snapshot.status, ExecutionProcessStatus::Exited); + assert_eq!(snapshot.exit_code, Some(0)); + assert_eq!(snapshot.output_bytes, output_bytes as u64); + assert_eq!(snapshot.output_omitted_bytes, 64 * 1024); + assert!(snapshot + .retained_output + .contains("... 65536 bytes omitted ...")); +} + #[tokio::test] async fn local_process_next_event_delivers_output_before_exit() { let mut handle = start_local_execution_process(LocalExecutionRequest::new( diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows.rs index 27d93886c..e5baeed93 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/windows.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows.rs @@ -169,7 +169,7 @@ pub(super) fn start_windows_restricted_execution_process( let process_state = ExecutionProcess::start(start); let initial_snapshot = process_state.snapshot(); let process = Arc::new(Mutex::new(process_state)); - let (output_tx, output_rx) = mpsc::unbounded_channel(); + let (output_tx, output_rx) = broadcast::channel(PROCESS_OUTPUT_CHANNEL_CAPACITY); let (control_tx, control_rx) = std::sync::mpsc::channel(); let (state_tx, state_rx) = watch::channel(initial_snapshot); let (final_tx, final_rx) = oneshot::channel(); diff --git a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs index 196f54aa6..e4884807f 100644 --- a/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs +++ b/lime-rs/crates/tool-runtime/src/execution_process/windows_runner_supervisor.rs @@ -14,7 +14,7 @@ use std::sync::mpsc::{self, Receiver, TryRecvError}; use std::sync::Arc; use std::thread; use std::time::Duration; -use tokio::sync::{mpsc as tokio_mpsc, oneshot, watch, Mutex}; +use tokio::sync::{broadcast, oneshot, watch, Mutex}; use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::System::Threading::{TerminateProcess, WaitForSingleObject}; @@ -24,7 +24,7 @@ pub(super) fn supervise( acl_lease: AclLease, null_device_lease: NullDeviceLease, process: Arc>, - output_tx: tokio_mpsc::UnboundedSender, + output_tx: broadcast::Sender, state_tx: watch::Sender, final_tx: oneshot::Sender, control_rx: Receiver, @@ -121,8 +121,9 @@ pub(super) fn supervise( } } - let final_snapshot = { + let (final_deltas, final_snapshot) = { let mut guard = process.blocking_lock(); + let final_deltas = guard.finish_all_output(); if !guard.status().is_terminal() { if let Some(error) = failure.as_ref() { guard.fail(error.clone()); @@ -130,8 +131,11 @@ pub(super) fn supervise( guard.exit(exit_code); } } - guard.snapshot() + (final_deltas, guard.snapshot()) }; + for delta in final_deltas { + let _ = output_tx.send(delta); + } let _ = state_tx.send(final_snapshot.clone()); let _ = final_tx.send(final_snapshot); @@ -169,7 +173,7 @@ fn forward_control( fn append_output( process: &Arc>, - output_tx: &tokio_mpsc::UnboundedSender, + output_tx: &broadcast::Sender, state_tx: &watch::Sender, kind: super::ExecutionOutputKind, bytes: &[u8], @@ -179,7 +183,9 @@ fn append_output( let delta = guard.append_output(kind, bytes); (delta, guard.snapshot()) }; - let _ = output_tx.send(delta); + if let Some(delta) = delta { + let _ = output_tx.send(delta); + } let _ = state_tx.send(snapshot); } diff --git a/lime-rs/crates/tool-runtime/src/tool_call.rs b/lime-rs/crates/tool-runtime/src/tool_call.rs index 78cc4d3f6..4338e15ad 100644 --- a/lime-rs/crates/tool-runtime/src/tool_call.rs +++ b/lime-rs/crates/tool-runtime/src/tool_call.rs @@ -79,6 +79,10 @@ impl ToolCall { &self.environments } + pub(crate) fn lifecycle_emitter(&self) -> Arc { + Arc::clone(&self.lifecycle_emitter) + } + pub async fn emit_started(&self) { self.lifecycle_emitter .emit(ToolLifecycleEvent::started(self)) diff --git a/lime-rs/crates/tool-runtime/src/tool_executor.rs b/lime-rs/crates/tool-runtime/src/tool_executor.rs index e28863401..db5b75b1c 100644 --- a/lime-rs/crates/tool-runtime/src/tool_executor.rs +++ b/lime-rs/crates/tool-runtime/src/tool_executor.rs @@ -19,7 +19,7 @@ pub struct RuntimeToolExecutionContextInput { pub workspace_sandbox: Option, } -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct RuntimeToolExecutionContext { working_directory: PathBuf, environment_id: Option, @@ -30,6 +30,30 @@ pub struct RuntimeToolExecutionContext { tool_identity: Option, execution_attempt: Option, filesystem_gateway: Option>, + lifecycle_emitter: Option>, +} + +impl std::fmt::Debug for RuntimeToolExecutionContext { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("RuntimeToolExecutionContext") + .field("working_directory", &self.working_directory) + .field("environment_id", &self.environment_id) + .field("session_id", &self.session_id) + .field("workspace_sandbox", &self.workspace_sandbox) + .field("environment", &self.environment) + .field("tool_identity", &self.tool_identity) + .field("execution_attempt", &self.execution_attempt) + .field("filesystem_gateway", &self.filesystem_gateway) + .field( + "lifecycle_emitter", + &self + .lifecycle_emitter + .as_ref() + .map(|_| ""), + ) + .finish() + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -67,6 +91,7 @@ impl RuntimeToolExecutionContext { tool_identity: None, execution_attempt: None, filesystem_gateway: None, + lifecycle_emitter: None, } } @@ -114,6 +139,12 @@ impl RuntimeToolExecutionContext { self.filesystem_gateway.as_ref() } + pub fn lifecycle_emitter( + &self, + ) -> Option<&Arc> { + self.lifecycle_emitter.as_ref() + } + pub fn with_tool_identity(mut self, identity: RuntimeToolExecutionIdentity) -> Self { self.tool_identity = Some(identity); self @@ -137,6 +168,14 @@ impl RuntimeToolExecutionContext { } } + pub(crate) fn with_lifecycle_emitter( + mut self, + emitter: Arc, + ) -> Self { + self.lifecycle_emitter = Some(emitter); + self + } + fn with_tool_environment(mut self, environment: &crate::tool_call::ToolEnvironment) -> Self { self.environment_id = Some(environment.environment_id.clone()); self.working_directory = environment.cwd.clone(); @@ -252,7 +291,8 @@ impl RuntimeToolExecutorHandle { .with_tool_identity(RuntimeToolExecutionIdentity::new( call.call_id(), call.turn_id(), - )); + )) + .with_lifecycle_emitter(call.lifecycle_emitter()); let context = match call.environments() { [] => context, [environment] => context.with_tool_environment(environment), diff --git a/lime-rs/crates/tool-runtime/src/tool_lifecycle.rs b/lime-rs/crates/tool-runtime/src/tool_lifecycle.rs index 1cec91743..11397c372 100644 --- a/lime-rs/crates/tool-runtime/src/tool_lifecycle.rs +++ b/lime-rs/crates/tool-runtime/src/tool_lifecycle.rs @@ -172,12 +172,20 @@ mod tests { #[derive(Default)] struct RecordingEmitter { events: Mutex>, + output_events: Mutex>, } impl RecordingEmitter { fn events(&self) -> Vec { self.events.lock().expect("recording emitter lock").clone() } + + fn output_events(&self) -> Vec { + self.output_events + .lock() + .expect("recording output emitter lock") + .clone() + } } impl ToolLifecycleEmitter for RecordingEmitter { @@ -189,10 +197,24 @@ mod tests { .push(event); }) } + + fn emit_output_delta<'a>( + &'a self, + event: ToolOutputDeltaEvent, + ) -> ToolLifecycleEmissionFuture<'a> { + Box::pin(async move { + self.output_events + .lock() + .expect("recording output emitter lock") + .push(event); + }) + } } struct StructuredExecutor; + struct OutputForwardingExecutor; + struct PendingExecutor; fn structured_result(text: impl Into) -> RuntimeToolExecutionResult { @@ -240,6 +262,34 @@ mod tests { } } + impl RuntimeToolExecutor for OutputForwardingExecutor { + fn execute<'a>( + &'a self, + request: RuntimeToolExecutionRequest<'a>, + ) -> RuntimeToolExecutionFuture<'a> { + let output_sink = request.context.lifecycle_emitter().cloned(); + Box::pin(async move { + output_sink + .expect("tool call lifecycle emitter should be attached") + .emit_output_delta(ToolOutputDeltaEvent { + turn_id: "turn-1".to_string(), + call_id: "call-output".to_string(), + tool_name: request.tool_name.to_string(), + delta: "streamed\n".to_string(), + output_kind: Some("stdout".to_string()), + metadata: HashMap::new(), + }) + .await; + Ok(RuntimeToolExecutionResult::new( + true, + "streamed\n".to_string(), + None, + HashMap::new(), + )) + }) + } + } + impl RuntimeToolExecutor for PendingExecutor { fn execute<'a>( &'a self, @@ -319,6 +369,44 @@ mod tests { assert_eq!(events[1].output, Some(output)); } + #[tokio::test] + async fn canonical_tool_contract_forwards_lifecycle_emitter_to_executor_context() { + let emitter = Arc::new(RecordingEmitter::default()); + let call = ToolCall::new( + "turn-1", + "call-output", + "exec_command", + serde_json::json!({ "cmd": "printf streamed" }), + vec![ToolEnvironment::new( + "local", + PathBuf::from("/tmp/workspace"), + )], + emitter.clone(), + ); + let runtime = RuntimeToolExecutorHandle::new(Arc::new(OutputForwardingExecutor)).bind( + RuntimeToolDefinition::new( + "exec_command", + "Execute a command", + serde_json::json!({ "type": "object" }), + ), + RuntimeToolExposure::Direct, + ); + let context = RuntimeToolExecutionContext::new(RuntimeToolExecutionContextInput { + working_directory: PathBuf::from("/tmp/workspace"), + session_id: "session-1".to_string(), + cancel_token: None, + workspace_sandbox: None, + }); + + runtime.execute_call(&call, &context, None).await; + + let output_events = emitter.output_events(); + assert_eq!(output_events.len(), 1); + assert_eq!(output_events[0].call_id, "call-output"); + assert_eq!(output_events[0].delta, "streamed\n"); + assert_eq!(output_events[0].output_kind.as_deref(), Some("stdout")); + } + #[tokio::test] async fn canonical_tool_contract_completes_name_mismatch_as_failure() { let emitter = Arc::new(RecordingEmitter::default()); diff --git a/lime-rs/crates/tool-runtime/src/unified_exec.rs b/lime-rs/crates/tool-runtime/src/unified_exec.rs index bba1d7ce6..57d56a645 100644 --- a/lime-rs/crates/tool-runtime/src/unified_exec.rs +++ b/lime-rs/crates/tool-runtime/src/unified_exec.rs @@ -2,7 +2,9 @@ use crate::execution_orchestrator::RuntimeToolExecutionAttempt; use crate::execution_process::live::{ LiveExecutionOutputQuery, LiveExecutionRequest, RuntimeLiveExecutionGateway, }; -use crate::execution_process::{ExecutionProcessStatus, TRAILING_OUTPUT_GRACE}; +use crate::execution_process::{ + BoundedProcessOutput, ExecutionOutputDelta, ExecutionProcessStatus, TRAILING_OUTPUT_GRACE, +}; use crate::tool_definition::RuntimeToolDefinition; use crate::tool_executor::{ RuntimeToolExecutionError, RuntimeToolExecutionResult, RuntimeToolPolicyErrorKind, @@ -11,6 +13,7 @@ use crate::tool_executor::{ use crate::tool_io::{ estimate_tool_io_tokens, format_tool_output_for_model, ToolOutputTruncationPolicy, }; +use crate::tool_lifecycle::{ToolLifecycleEmitter, ToolOutputDeltaEvent}; use serde::Deserialize; use serde_json::{json, Value}; use std::collections::HashMap; @@ -45,9 +48,11 @@ pub struct RuntimeUnifiedExecToolRequest<'a> { pub working_directory: PathBuf, pub environment: HashMap, pub tool_call_id: String, + pub turn_id: String, pub cancel_token: Option, pub turn_context: Option<&'a RuntimeToolTurnContext>, pub attempt: Option, + pub output_sink: Option>, } #[derive(Debug, Deserialize)] @@ -89,6 +94,7 @@ struct WriteStdinInput { #[derive(Debug)] struct UnifiedExecSession { thread_id: String, + turn_id: String, process_id: String, call_id: String, command: String, @@ -114,6 +120,7 @@ struct UnifiedExecCallOutput { command: String, cwd: String, output: String, + process: crate::execution_process::ExecutionProcessSnapshot, exit_code: Option, observation: UnifiedExecObservationKind, empty_poll_count: u64, @@ -273,6 +280,7 @@ async fn execute_command( let cwd_text = cwd.to_string_lossy().to_string(); let session = Arc::new(tokio::sync::Mutex::new(UnifiedExecSession { thread_id: request.thread_id.to_string(), + turn_id: request.turn_id.clone(), process_id: process_id.clone(), call_id: call_id.clone(), command: command.clone(), @@ -329,6 +337,8 @@ async fn execute_command( gateway, session_id, session, + request.turn_id, + request.output_sink, clamped_exec_yield_time(input.yield_time_ms), clamped_output_tokens(input.max_output_tokens), request.cancel_token, @@ -346,7 +356,8 @@ async fn write_stdin( unified_exec_error(format!("write_stdin arguments are invalid: {error}")) })?; let session = find_session(input.session_id)?; - let process_id = session_facts(&session).await.process_id; + let session_identity = session_facts(&session).await; + let process_id = session_identity.process_id.clone(); { let session = session.lock().await; if session.thread_id != request.thread_id { @@ -362,6 +373,8 @@ async fn write_stdin( gateway, input.session_id, session, + session_identity.turn_id, + request.output_sink, clamped_write_yield_time(input.yield_time_ms, input.chars.is_empty()), clamped_output_tokens(input.max_output_tokens), request.cancel_token, @@ -389,13 +402,15 @@ async fn collect_process_output( gateway: Arc, session_id: i32, session: Arc>, + turn_id: String, + output_sink: Option>, yield_time: Duration, max_output_tokens: usize, cancel_token: Option, ) -> Result { let started_at = Instant::now(); let deadline = started_at + yield_time; - let mut output = String::new(); + let mut output = BoundedProcessOutput::default(); loop { let (process_id, after_sequence) = { @@ -408,8 +423,8 @@ async fn collect_process_output( limit: Some(OUTPUT_DRAIN_LIMIT), max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES), })?; - for delta in drained.deltas { - output.push_str(&delta.delta); + for delta in &drained.deltas { + append_observed_output(&mut output, delta); } if let Some(next_sequence) = drained.next_sequence { session.lock().await.after_sequence = Some(next_sequence); @@ -423,6 +438,15 @@ async fn collect_process_output( } let snapshot = gateway.status(&process_id)?; + let call_id = session_facts(&session).await.call_id; + emit_output_deltas( + output_sink.as_ref(), + &turn_id, + &call_id, + &drained.deltas, + snapshot.status, + ) + .await; if process_status_is_terminal(snapshot.status) { tokio::time::sleep(TRAILING_OUTPUT_GRACE).await; let final_drain = gateway.drain_output(LiveExecutionOutputQuery { @@ -431,9 +455,17 @@ async fn collect_process_output( limit: Some(OUTPUT_DRAIN_LIMIT), max_bytes: Some(OUTPUT_DRAIN_MAX_BYTES), })?; - for delta in final_drain.deltas { - output.push_str(&delta.delta); + for delta in &final_drain.deltas { + append_observed_output(&mut output, delta); } + emit_output_deltas( + output_sink.as_ref(), + &turn_id, + &call_id, + &final_drain.deltas, + snapshot.status, + ) + .await; let facts = session_facts(&session).await; remove_session(session_id); if cancelled { @@ -447,6 +479,7 @@ async fn collect_process_output( if snapshot.status == ExecutionProcessStatus::Failed { let message = snapshot .failure + .clone() .unwrap_or_else(|| "execution process failed".to_string()); return Err(RuntimeToolExecutionError::new( message, @@ -455,13 +488,15 @@ async fn collect_process_output( )), )); } + let exit_code = snapshot.exit_code; return Ok(UnifiedExecCallOutput { session_id: None, call_id: facts.call_id, command: facts.command, cwd: facts.cwd, - output, - exit_code: snapshot.exit_code, + output: output.snapshot().text, + process: snapshot, + exit_code, observation: UnifiedExecObservationKind::Terminal, empty_poll_count: 0, wall_time: started_at.elapsed(), @@ -483,7 +518,8 @@ async fn collect_process_output( call_id: facts.call_id.clone(), command: facts.command.clone(), cwd: facts.cwd.clone(), - output, + output: output.snapshot().text, + process: snapshot, exit_code: None, observation, empty_poll_count: facts.empty_poll_count, @@ -495,10 +531,61 @@ async fn collect_process_output( } } +fn append_observed_output(output: &mut BoundedProcessOutput, delta: &ExecutionOutputDelta) { + if delta.raw_bytes.is_empty() { + output.push(delta.delta.as_bytes()); + } else { + output.push(&delta.raw_bytes); + } +} + +async fn emit_output_deltas( + output_sink: Option<&Arc>, + turn_id: &str, + call_id: &str, + deltas: &[ExecutionOutputDelta], + process_status: ExecutionProcessStatus, +) { + let Some(output_sink) = output_sink else { + return; + }; + for delta in deltas { + if delta.delta.is_empty() { + continue; + } + let mut metadata = delta.metadata(); + metadata.insert( + "executionProcessStatus".to_string(), + json!(process_status.label()), + ); + metadata.insert( + "stdinWritable".to_string(), + json!(!process_status.is_terminal()), + ); + metadata.insert( + "stdin_writable".to_string(), + json!(!process_status.is_terminal()), + ); + metadata.insert("executionSurface".to_string(), json!("unified_exec")); + metadata.insert("execution_surface".to_string(), json!("unified_exec")); + output_sink + .emit_output_delta(ToolOutputDeltaEvent { + turn_id: turn_id.to_string(), + call_id: call_id.to_string(), + tool_name: EXEC_COMMAND_TOOL_NAME.to_string(), + delta: delta.delta.clone(), + output_kind: Some(delta.kind.label().to_string()), + metadata, + }) + .await; + } +} + async fn session_facts(session: &tokio::sync::Mutex) -> UnifiedExecSession { let session = session.lock().await; UnifiedExecSession { thread_id: session.thread_id.clone(), + turn_id: session.turn_id.clone(), process_id: session.process_id.clone(), call_id: session.call_id.clone(), command: session.command.clone(), @@ -531,9 +618,12 @@ fn project_output(output: UnifiedExecCallOutput) -> RuntimeToolExecutionResult { if let Some(exit_code) = output.exit_code { structured["exit_code"] = json!(exit_code); } + structured["process_id"] = json!(output.process.process_id.clone()); + structured["process_status"] = json!(output.process.status.label()); let success = output.exit_code.map(|code| code == 0).unwrap_or(true); let serialized = serde_json::to_string(&structured).unwrap_or_else(|_| "{}".to_string()); - let metadata = HashMap::from([ + let mut metadata = output.process.metadata(); + metadata.extend([ ("exec_command_call_id".to_string(), json!(output.call_id)), ("command".to_string(), json!(output.command)), ("cwd".to_string(), json!(output.cwd)), @@ -557,6 +647,7 @@ fn project_output(output: UnifiedExecCallOutput) -> RuntimeToolExecutionResult { json!(output.empty_poll_count), ), ("execution_surface".to_string(), json!("unified_exec")), + ("executionSurface".to_string(), json!("unified_exec")), ]); RuntimeToolExecutionResult::new( success, diff --git a/lime-rs/crates/tool-runtime/src/unified_exec/tests.rs b/lime-rs/crates/tool-runtime/src/unified_exec/tests.rs index 5b1d3494c..0e72ee032 100644 --- a/lime-rs/crates/tool-runtime/src/unified_exec/tests.rs +++ b/lime-rs/crates/tool-runtime/src/unified_exec/tests.rs @@ -4,6 +4,9 @@ use crate::execution_process::{ ExecutionOutputDelta, ExecutionOutputKind, ExecutionProcessSnapshot, }; use crate::tool_executor::{RuntimeToolExecutionError, RuntimeToolPolicyErrorKind}; +use crate::tool_lifecycle::{ + ToolLifecycleEmissionFuture, ToolLifecycleEmitter, ToolLifecycleEvent, ToolOutputDeltaEvent, +}; use async_trait::async_trait; #[cfg(target_os = "windows")] @@ -250,9 +253,37 @@ fn request<'a>( working_directory: std::env::current_dir().unwrap(), environment: HashMap::new(), tool_call_id: call_id.to_string(), + turn_id: "turn-1".to_string(), cancel_token: None, turn_context: None, attempt: None, + output_sink: None, + } +} + +#[derive(Default)] +struct RecordingOutputSink { + events: Mutex>, +} + +impl RecordingOutputSink { + fn events(&self) -> Vec { + self.events.lock().unwrap().clone() + } +} + +impl ToolLifecycleEmitter for RecordingOutputSink { + fn emit<'a>(&'a self, _event: ToolLifecycleEvent) -> ToolLifecycleEmissionFuture<'a> { + Box::pin(async {}) + } + + fn emit_output_delta<'a>( + &'a self, + event: ToolOutputDeltaEvent, + ) -> ToolLifecycleEmissionFuture<'a> { + Box::pin(async move { + self.events.lock().unwrap().push(event); + }) } } @@ -274,18 +305,18 @@ fn definitions_expose_only_codex_unified_exec_tools() { #[tokio::test] async fn exec_command_returns_terminal_output_for_short_process() { let gateway = Arc::new(FixtureGateway::default()); + let output_sink = Arc::new(RecordingOutputSink::default()); let params = json!({ "cmd": "short", "login": false, "yield_time_ms": 250 }); - let result = execute_runtime_unified_exec_tool( - gateway, - request(EXEC_COMMAND_TOOL_NAME, ¶ms, "call-short"), - ) - .await - .expect("short command result"); + let mut execution_request = request(EXEC_COMMAND_TOOL_NAME, ¶ms, "call-short"); + execution_request.output_sink = Some(output_sink.clone()); + let result = execute_runtime_unified_exec_tool(gateway, execution_request) + .await + .expect("short command result"); assert!(result.success); let structured = result.structured_content.expect("structured output"); @@ -298,6 +329,35 @@ async fn exec_command_returns_terminal_output_for_short_process() { result.metadata.get("exec_command_call_id"), Some(&json!("call-short")) ); + assert!(result + .metadata + .get("processId") + .and_then(Value::as_str) + .is_some_and(|value| value.starts_with("unified-exec-"))); + assert_eq!( + result.metadata.get("executionProcessStatus"), + Some(&json!("exited")) + ); + assert_eq!(result.metadata.get("outputTruncated"), Some(&json!(false))); + let events = output_sink.events(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].turn_id, "turn-1"); + assert_eq!(events[0].call_id, "call-short"); + assert_eq!(events[0].delta, "completed\n"); + assert_eq!(events[0].output_kind.as_deref(), Some("stdout")); + assert_eq!( + events[0].metadata.get("processId"), + result.metadata.get("processId") + ); + assert_eq!(events[0].metadata.get("outputSequence"), Some(&json!(1))); + assert_eq!( + events[0].metadata.get("executionProcessStatus"), + Some(&json!("exited")) + ); + assert_eq!( + events[0].metadata.get("executionSurface"), + Some(&json!("unified_exec")) + ); } #[tokio::test] @@ -381,35 +441,43 @@ async fn exec_command_forwards_environment_identity_to_execution_gateway() { #[tokio::test] async fn write_stdin_resumes_and_completes_original_exec_command() { let gateway = Arc::new(FixtureGateway::default()); + let output_sink = Arc::new(RecordingOutputSink::default()); let exec_params = json!({ "cmd": "long-running", "login": false, "yield_time_ms": 250 }); - let running = execute_runtime_unified_exec_tool( - gateway.clone(), - request(EXEC_COMMAND_TOOL_NAME, &exec_params, "call-long"), - ) - .await - .expect("running command result"); + let mut exec_request = request(EXEC_COMMAND_TOOL_NAME, &exec_params, "call-long"); + exec_request.output_sink = Some(output_sink.clone()); + let running = execute_runtime_unified_exec_tool(gateway.clone(), exec_request) + .await + .expect("running command result"); let session_id = running .structured_content .as_ref() .and_then(|value| value.get("session_id")) .and_then(Value::as_i64) .expect("session id") as i32; + assert_eq!( + running.metadata.get("executionProcessStatus"), + Some(&json!("running")) + ); + assert_eq!( + running.metadata.get("processId"), + Some(&json!(format!("unified-exec-{session_id}"))) + ); let write_params = json!({ "session_id": session_id, "chars": "continue\n", "yield_time_ms": 250 }); - let completed = execute_runtime_unified_exec_tool( - gateway, - request(WRITE_STDIN_TOOL_NAME, &write_params, "write-call"), - ) - .await - .expect("write stdin result"); + let mut write_request = request(WRITE_STDIN_TOOL_NAME, &write_params, "write-call"); + write_request.turn_id = "turn-write".to_string(); + write_request.output_sink = Some(output_sink.clone()); + let completed = execute_runtime_unified_exec_tool(gateway, write_request) + .await + .expect("write stdin result"); assert!(completed.success); let structured = completed.structured_content.expect("structured output"); @@ -427,6 +495,12 @@ async fn write_stdin_resumes_and_completes_original_exec_command() { "stdin": "sent 9 chars", })) ); + let events = output_sink.events(); + assert_eq!(events.len(), 2); + assert!(events.iter().all(|event| event.turn_id == "turn-1")); + assert!(events.iter().all(|event| event.call_id == "call-long")); + assert_eq!(events[0].delta, "started\n"); + assert_eq!(events[1].delta, "finished\n"); } #[tokio::test] diff --git a/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs b/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs index f6dcd3085..ed4ec7373 100644 --- a/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs +++ b/lime-rs/crates/tool-runtime/tests/windows_restricted_execution.rs @@ -10,6 +10,7 @@ use app_server_protocol::protocol::v2::{AdditionalNetworkPermissions, GrantedPer use tool_runtime::execution_process::{ audit_windows_world_writable, start_local_execution_process, ExecutionProcessStatus, LocalExecutionProcessHandle, LocalExecutionRequest, LocalExecutionSandbox, + DEFAULT_OUTPUT_RETAIN_BYTES, }; use tool_runtime::sandbox::SandboxBackend; use windows_sys::Win32::Foundation::{LocalFree, ERROR_SUCCESS, HLOCAL}; @@ -380,11 +381,12 @@ async fn restricted_execution_uses_offline_account_and_blocks_network() { #[tokio::test] async fn restricted_execution_bounds_large_output() { let fixture = workspace_fixture(); - let script = "[Console]::Out.Write(('x' * 400000))"; + let output_bytes = DEFAULT_OUTPUT_RETAIN_BYTES + 64 * 1024; + let script = format!("[Console]::Out.Write(('x' * {output_bytes}))"); let snapshot = run_to_terminal( start_local_execution_process(restricted_request( fixture.path(), - powershell_script(script), + powershell_script(&script), )) .expect("large-output process should start"), ) @@ -397,11 +399,16 @@ async fn restricted_execution_bounds_large_output() { "large-output restricted process must exit successfully: {snapshot:#?}" ); assert!(snapshot.output_truncated, "large output must be truncated"); - assert!( - snapshot.output_omitted_bytes > 0, - "omitted byte count must be reported" + assert_eq!( + snapshot.output_omitted_bytes, + output_bytes.saturating_sub(DEFAULT_OUTPUT_RETAIN_BYTES) as u64 + ); + let marker = format!("... {} bytes omitted ...", snapshot.output_omitted_bytes); + assert!(snapshot.retained_output.contains(&marker)); + assert_eq!( + snapshot.retained_output.len(), + DEFAULT_OUTPUT_RETAIN_BYTES + marker.len() + 2 ); - assert!(snapshot.retained_output.len() <= 128 * 1024); } #[tokio::test] diff --git a/lime-rs/crates/tui/Cargo.toml b/lime-rs/crates/tui/Cargo.toml index 23e028d60..eee7535bd 100644 --- a/lime-rs/crates/tui/Cargo.toml +++ b/lime-rs/crates/tui/Cargo.toml @@ -11,15 +11,16 @@ path = "src/lib.rs" doctest = false [dependencies] +agent-protocol.workspace = true anyhow.workspace = true app-server-client.workspace = true app-server-protocol.workspace = true arboard.workspace = true base64.workspace = true -crossterm.workspace = true +crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } futures.workspace = true image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "gif", "webp"] } -ratatui = { workspace = true, features = ["unstable-rendered-line-info"] } +ratatui = { workspace = true, features = ["unstable-rendered-line-info", "unstable-widget-ref"] } pulldown-cmark.workspace = true serde.workspace = true serde_json = { workspace = true, features = ["preserve_order"] } @@ -28,6 +29,7 @@ tokio.workspace = true tokio-stream = { workspace = true, features = ["sync"] } tokio-util = { workspace = true, features = ["time"] } tempfile.workspace = true +tracing.workspace = true two-face = { version = "0.5", default-features = false, features = ["syntect-default-onig"] } unicode-segmentation.workspace = true unicode-width.workspace = true @@ -35,13 +37,15 @@ textwrap.workspace = true url.workspace = true urlencoding.workspace = true -[target.'cfg(target_os = "macos")'.dependencies] +[target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Console"] } +which.workspace = true + [dev-dependencies] portable-pty.workspace = true pretty_assertions.workspace = true tokio = { workspace = true, features = ["test-util"] } - -[target.'cfg(windows)'.dependencies] -which.workspace = true +vt100.workspace = true diff --git a/lime-rs/crates/tui/src/app.rs b/lime-rs/crates/tui/src/app.rs index cb48893b1..ca9f6a9cc 100644 --- a/lime-rs/crates/tui/src/app.rs +++ b/lime-rs/crates/tui/src/app.rs @@ -1,17 +1,48 @@ -use app_server_protocol::protocol::v2::{QueuedSubmission, ServerNotification, Thread}; +mod agent_navigation; +pub(crate) mod agent_picker; +pub(crate) mod agents_overview; +pub(crate) mod agents_overview_threads; +pub(crate) mod agents_overview_view; +pub(crate) mod app_server_event_targets; +mod app_server_events; +pub(crate) mod app_server_requests; +pub(crate) mod event_dispatch; +mod input; +mod pending_interactive_replay; +pub(crate) mod reconnect; +mod replay_filter; +mod session_lifecycle; +mod thread_event_buffer; +mod thread_events; +mod thread_settings; + +#[cfg(test)] +use app_server_protocol::protocol::v2::ServerNotification; +use app_server_protocol::protocol::v2::{QueuedSubmission, Thread}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use std::collections::HashMap; use std::path::PathBuf; use std::time::{Duration, Instant}; +use self::agent_navigation::{AgentNavigationDirection, AgentNavigationState}; +use self::agent_picker::{AgentPicker, AgentPickerAction}; +use self::agents_overview::AgentsOverviewState; +use self::agents_overview_view::AgentsOverviewAction; use crate::bottom_pane::{AppServerResponse, BottomPane, ChatComposer, InputResult}; use crate::command_popup::{CommandPopup, CommandPopupAction}; use crate::locale::Locale; +use crate::model_catalog::ModelCatalog; use crate::model_picker::{ModelPicker, ModelPickerAction, ModelSelection}; use crate::pager_overlay::{PagerAction, PagerOverlay, StatusFacts}; use crate::pending_input_preview::can_restore_submission; use crate::projection::ConversationProjection; -use crate::settings::{PERMISSION_PROFILES, cycle_setting}; -use crate::slash_command::{SlashCommand, command_from_prompt}; +use crate::resume_picker::{PickerAction, PickerState}; +use crate::slash_command::{command_from_prompt, SlashCommand}; +use crate::tui::TuiEvent; + +fn normalize_paste(text: String) -> String { + text.replace("\r\n", "\n").replace('\r', "\n") +} #[derive(Debug, PartialEq)] pub(crate) enum AppAction { @@ -32,6 +63,22 @@ pub(crate) enum AppAction { ScrollTop, ScrollBottom, SelectModel(ModelSelection), + ChangeCollaborationMode(agent_protocol::CollaborationMode), + SwitchThread(String), + RefreshAgentsOverview, + DispatchAgentsOverviewTask { + prompt: String, + cwd: Option, + }, + RenameAgentsOverviewThread { + thread_id: String, + name: String, + }, + StopAgentsOverviewThread { + thread_id: String, + }, + OpenResumePicker, + ResumePicker(PickerAction), Respond(AppServerResponse), Quit, } @@ -42,9 +89,17 @@ pub(crate) struct App { pub(crate) composer: ChatComposer, pub(crate) projection: ConversationProjection, pub(crate) model_picker: Option, + pub(crate) agent_picker: Option, + pub(crate) agents_overview: Option, + pub(crate) resume_picker: Option, + pub(crate) model_catalog: ModelCatalog, + pub(crate) collaboration_mode: Option, pub(crate) command_popup: Option, pub(crate) pager_overlay: Option, pub(crate) thread_id: Option, + pub(crate) primary_thread_id: Option, + pub(crate) agent_navigation: AgentNavigationState, + thread_event_channels: HashMap, pub(crate) model: Option, pub(crate) model_provider: Option, pub(crate) reasoning_effort: Option, @@ -54,8 +109,8 @@ pub(crate) struct App { pub(crate) locale: Locale, pub(crate) cwd: PathBuf, pub(crate) clipboard_lease: Option, - pub(crate) pending_images: Vec, pub(crate) queued_submissions: Vec, + pub(crate) thread_input_states: HashMap, active_turn_started_at: Option, } @@ -68,46 +123,62 @@ impl App { if self.thread_id.as_deref() != Some(thread_id.as_str()) { self.queued_submissions.clear(); } + if self.primary_thread_id.is_none() { + self.primary_thread_id = Some(thread_id.clone()); + } + if self.agent_navigation.get(&thread_id).is_none() { + self.agent_navigation + .upsert(thread_id.clone(), None, None, false); + } + self.ensure_thread_channel(&thread_id); self.thread_id = Some(thread_id); } + pub(crate) fn capture_current_thread_input(&mut self) { + let Some(thread_id) = self.thread_id.clone() else { + return; + }; + let draft = self.composer.text().to_string(); + self.thread_input_states + .insert(thread_id.clone(), draft.clone()); + if let Some(overview) = self.agents_overview.as_mut() { + overview.input_states.insert(thread_id, draft); + } + } + + pub(crate) fn restore_thread_input(&mut self, thread_id: &str) { + let draft = self + .thread_input_states + .get(thread_id) + .cloned() + .or_else(|| { + self.agents_overview + .as_ref() + .and_then(|overview| overview.input_states.get(thread_id)) + .cloned() + }) + .unwrap_or_default(); + self.composer.replace(draft); + self.sync_command_popup(); + } + pub(crate) fn set_locale(&mut self, locale: Locale) { self.locale = locale; } - pub(crate) fn set_settings( - &mut self, - model: Option, - model_provider: Option, - reasoning_effort: Option, - permissions: Option, - ) { - self.model = model; - self.model_provider = model_provider; - self.reasoning_effort = reasoning_effort; - self.permissions = permissions; - } - - pub(crate) fn set_permission_profiles(&mut self, profiles: impl IntoIterator) { - let mut next = Vec::new(); - for profile in profiles { - let profile = profile.trim(); - if profile.is_empty() || next.iter().any(|value| value == profile) { - continue; - } - next.push(profile.to_string()); - } - self.permission_profiles = next; - } - - pub(crate) fn cycle_permission_profile(&self, current: Option<&str>, direction: i8) -> String { - if self.permission_profiles.is_empty() { - return cycle_setting(&PERMISSION_PROFILES, current, direction); - } - cycle_setting(&self.permission_profiles, current, direction) - } - pub(crate) fn hydrate_thread(&mut self, thread: Thread) { + self.agent_navigation.upsert( + thread.id.clone(), + thread.agent_nickname.clone(), + thread.agent_role.clone(), + false, + ); + if let Some(parent_thread_id) = thread.parent_thread_id.clone() { + self.agent_navigation.mark_parent_owned(thread.id.clone()); + if self.primary_thread_id.is_none() { + self.primary_thread_id = Some(parent_thread_id); + } + } self.projection.hydrate_thread(thread); self.active_turn_started_at = self .projection @@ -121,13 +192,10 @@ impl App { self.active_turn_started_at = Some(Instant::now()); } - pub(crate) fn apply_notification(&mut self, notification: ServerNotification) { - let previous_turn_id = self.projection.active_turn_id().map(str::to_owned); - self.projection.apply(notification); - let active_turn_id = self.projection.active_turn_id(); - if active_turn_id != previous_turn_id.as_deref() { - self.active_turn_started_at = active_turn_id.map(|_| Instant::now()); - } + fn adjacent_agent(&self, direction: AgentNavigationDirection) -> Option { + self.agent_navigation + .adjacent_thread_id(self.thread_id.as_deref(), direction) + .filter(|thread_id| self.thread_id.as_deref() != Some(thread_id.as_str())) } pub(crate) fn active_turn_elapsed(&self, now: Instant) -> Option { @@ -139,11 +207,17 @@ impl App { ) } - pub(crate) fn open_model_picker( - &mut self, - models: Vec, - ) { - self.model_picker = Some(ModelPicker::new(models)); + pub(crate) fn can_accept_direct_input(&mut self) -> bool { + if self + .thread_id + .as_deref() + .is_some_and(|thread_id| self.agent_navigation.is_parent_owned(thread_id)) + { + self.projection + .set_status("sub-agent thread is parent-owned"); + return false; + } + true } pub(crate) fn replace_composer(&mut self, text: String) { @@ -151,7 +225,39 @@ impl App { self.sync_command_popup(); } - pub(crate) fn handle_terminal_event(&mut self, event: Event) -> AppAction { + pub(crate) fn handle_tui_event(&mut self, event: TuiEvent, connected: bool) -> AppAction { + if !connected { + return match event { + TuiEvent::Key(key) + if key.kind == KeyEventKind::Press + && key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char(value) if value.eq_ignore_ascii_case(&'c')) => + { + AppAction::Quit + } + TuiEvent::Key(key) => { + self.composer.handle_disconnected_key(key); + self.command_popup = None; + AppAction::None + } + TuiEvent::Paste(text) => { + self.composer.insert(&normalize_paste(text)); + self.command_popup = None; + AppAction::None + } + _ => AppAction::None, + }; + } + + let event = match event { + TuiEvent::Key(key) => Event::Key(key), + TuiEvent::Paste(text) => Event::Paste(normalize_paste(text)), + TuiEvent::Resize(size) => Event::Resize(size.width, size.height), + TuiEvent::FocusGained => Event::FocusGained, + TuiEvent::FocusLost => Event::FocusLost, + TuiEvent::Draw | TuiEvent::Resume => return AppAction::None, + }; + if let Some(pager) = self.pager_overlay.as_mut() { if pager.handle_event(&event) == PagerAction::Close { self.pager_overlay = None; @@ -159,22 +265,6 @@ impl App { return AppAction::None; } - if let Event::Key(ref key) = event { - if key.kind == KeyEventKind::Press { - match key.code { - KeyCode::PageUp => return AppAction::ScrollUp, - KeyCode::PageDown => return AppAction::ScrollDown, - KeyCode::Home if key.modifiers.contains(KeyModifiers::ALT) => { - return AppAction::ScrollTop; - } - KeyCode::End if key.modifiers.contains(KeyModifiers::ALT) => { - return AppAction::ScrollBottom; - } - _ => {} - } - } - } - if self.bottom_pane.is_active() { return self .bottom_pane @@ -183,6 +273,57 @@ impl App { .unwrap_or(AppAction::None); } + if let Some(picker) = self.resume_picker.as_mut() { + if picker.transcript_pager_is_open() { + picker.handle_transcript_pager_event(&event); + return AppAction::None; + } + let action = picker.handle_event(event); + if action == PickerAction::Cancel { + self.resume_picker = None; + return AppAction::None; + } + return AppAction::ResumePicker(action); + } + + if let Some(overview) = self.agents_overview.as_mut() { + let action = overview.view.handle_event(event); + overview.visible_thread_ids = overview + .view + .visible_rows() + .into_iter() + .map(|row| row.thread.id.clone()) + .collect(); + overview.sync_view_state(); + return match action { + AgentsOverviewAction::Select => { + let thread_id = overview.view.selected_thread_id().map(str::to_owned); + self.agents_overview = None; + self.agent_picker = None; + thread_id + .map(AppAction::SwitchThread) + .unwrap_or(AppAction::None) + } + AgentsOverviewAction::Cancel => { + self.agents_overview = None; + self.agent_picker = None; + AppAction::None + } + AgentsOverviewAction::Refresh => AppAction::RefreshAgentsOverview, + AgentsOverviewAction::Dispatch { prompt, cwd } => { + AppAction::DispatchAgentsOverviewTask { prompt, cwd } + } + AgentsOverviewAction::Rename { thread_id, name } => { + AppAction::RenameAgentsOverviewThread { thread_id, name } + } + AgentsOverviewAction::Stop { thread_id } => { + AppAction::StopAgentsOverviewThread { thread_id } + } + AgentsOverviewAction::OpenResumePicker => AppAction::OpenResumePicker, + AgentsOverviewAction::None => AppAction::None, + }; + } + if let Some(picker) = self.model_picker.as_mut() { return match picker.handle_event(event) { ModelPickerAction::Select(index) => { @@ -200,6 +341,20 @@ impl App { }; } + if let Some(picker) = self.agent_picker.as_mut() { + return match picker.handle_event(event) { + AgentPickerAction::Select(thread_id) => { + self.agent_picker = None; + AppAction::SwitchThread(thread_id) + } + AgentPickerAction::Cancel => { + self.agent_picker = None; + AppAction::None + } + AgentPickerAction::None => AppAction::None, + }; + } + if let Some(popup) = self.command_popup.as_mut() { match popup.handle_event(&event) { CommandPopupAction::Pass => {} @@ -238,81 +393,7 @@ impl App { } match event { - Event::Key(key) - if key.kind == KeyEventKind::Press - && key.code == KeyCode::Esc - && self.projection.active_turn_id().is_some() => - { - AppAction::Interrupt - } - Event::Key(key) - if key.kind == KeyEventKind::Press - && key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) - && matches!(key.code, KeyCode::Char(value) if value.eq_ignore_ascii_case(&'v')) => - { - AppAction::PasteImage - } - Event::Key(key) - if key.kind == KeyEventKind::Press - && key.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key.code, KeyCode::Char(value) if value.eq_ignore_ascii_case(&'o')) => - { - AppAction::CopyLastResponse - } - Event::Key(key) - if key.kind == KeyEventKind::Press - && key.modifiers.contains(KeyModifiers::CONTROL) - && matches!(key.code, KeyCode::Char(value) if value.eq_ignore_ascii_case(&'t')) => - { - self.command_popup = None; - self.pager_overlay = Some(PagerOverlay::transcript(self.locale)); - AppAction::None - } - Event::Key(key) - if key.kind == KeyEventKind::Press - && key.code == KeyCode::Up - && key.modifiers.contains(KeyModifiers::ALT) - && self.composer.is_empty() - && self.pending_images.is_empty() => - { - self.queued_submissions - .last() - .filter(|submission| can_restore_submission(submission)) - .cloned() - .map(AppAction::EditQueuedSubmission) - .unwrap_or(AppAction::None) - } - Event::Key(key) if key.kind == KeyEventKind::Press => { - if key.code == KeyCode::Enter - && !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT) - { - if let Some(action) = self.run_local_command() { - return action; - } - } - if matches!(key.code, KeyCode::Enter | KeyCode::Tab) - && self.composer.is_empty() - && !self.pending_images.is_empty() - { - return if key.code == KeyCode::Tab { - AppAction::Queue(String::new()) - } else { - AppAction::Submit(String::new()) - }; - } - if key.code == KeyCode::Backspace - && self.composer.is_empty() - && self.pending_images.pop().is_some() - { - return AppAction::None; - } - let action = self.composer.handle_key_event(key); - self.map_composer_action(action) - } + Event::Key(key) => self.handle_key_event(key), Event::Paste(text) => { self.composer.insert(&text); self.sync_command_popup(); @@ -339,15 +420,15 @@ impl App { } pub(crate) fn attach_image(&mut self, path: PathBuf) { - self.pending_images.push(path); + self.composer.attach_image(path); } pub(crate) fn take_pending_images(&mut self) -> Vec { - std::mem::take(&mut self.pending_images) + self.composer.take_pending_images() } pub(crate) fn restore_pending_images(&mut self, images: Vec) { - self.pending_images = images; + self.composer.restore_pending_images(images); } pub(crate) fn set_queued_submissions(&mut self, submissions: Vec) { @@ -371,7 +452,7 @@ impl App { submission: QueuedSubmission, ) -> bool { if !self.composer.is_empty() - || !self.pending_images.is_empty() + || self.composer.has_pending_images() || !can_restore_submission(&submission) { return false; @@ -393,7 +474,7 @@ impl App { self.queued_submissions .retain(|queued| queued.id != submission_id); self.replace_composer(text); - self.pending_images = images; + self.composer.restore_pending_images(images); true } @@ -455,6 +536,15 @@ impl App { AppAction::None } SlashCommand::Copy => AppAction::CopyLastResponse, + SlashCommand::Agents => { + self.open_agents_overview(); + AppAction::RefreshAgentsOverview + } + SlashCommand::MultiAgents => { + self.open_agent_picker(); + AppAction::None + } + SlashCommand::Resume => AppAction::OpenResumePicker, _ => return None, }; self.composer.replace(String::new()); @@ -480,513 +570,4 @@ impl App { } #[cfg(test)] -mod tests { - use super::*; - use app_server_protocol::RequestId; - use app_server_protocol::protocol::v2::{ - CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, ServerRequest, - UserInput, - }; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; - - #[test] - fn active_bottom_pane_receives_input_before_the_chat_composer() { - let mut app = App::default(); - app.composer.insert("draft"); - app.bottom_pane - .enqueue(ServerRequest::ItemCommandExecutionRequestApproval { - id: RequestId::Integer(7), - params: CommandExecutionRequestApprovalParams { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - item_id: "command-1".to_string(), - started_at_ms: 1, - approval_id: None, - reason: None, - network_approval_context: None, - command: Some("cargo test".to_string()), - cwd: Some("/workspace".to_string()), - available_decisions: None, - }, - }) - .expect("queue approval"); - - let ignored = app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('x'), - KeyModifiers::NONE, - ))); - assert_eq!(ignored, AppAction::None); - assert_eq!(app.composer.text(), "draft"); - - let response = app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))); - assert!(matches!( - response, - AppAction::Respond(AppServerResponse::Command { - response: - app_server_protocol::protocol::v2::CommandExecutionRequestApprovalResponse { - decision: CommandExecutionApprovalDecision::Accept, - }, - .. - }) - )); - assert!(!app.bottom_pane.is_active()); - assert_eq!(app.composer.text(), "draft"); - } - - #[test] - fn tab_queues_a_follow_up_without_submitting_the_active_turn() { - let mut app = App::default(); - app.composer.insert("follow up"); - app.projection.apply( - app_server_protocol::protocol::v2::ServerNotification::TurnStarted( - app_server_protocol::protocol::v2::TurnStartedNotification { - thread_id: "thread-1".to_string(), - turn: app_server_protocol::protocol::v2::Turn { - id: "turn-1".to_string(), - items: Vec::new(), - items_view: Default::default(), - status: app_server_protocol::protocol::v2::TurnStatus::InProgress, - error: None, - started_at: None, - completed_at: None, - duration_ms: None, - }, - }, - ), - ); - - let action = - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE))); - - assert_eq!(action, AppAction::Queue("follow up".to_string())); - assert!(app.composer.is_empty()); - } - - #[test] - fn escape_interrupts_only_an_active_turn_and_preserves_the_draft() { - let mut app = App::default(); - app.composer.insert("keep this draft"); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))), - AppAction::None - ); - - app.start_turn("turn-1".to_string()); - assert!(app.active_turn_elapsed(Instant::now()).is_some()); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))), - AppAction::Interrupt - ); - assert_eq!(app.composer.text(), "keep this draft"); - } - - #[test] - fn turn_completion_clears_the_active_status_timer() { - use app_server_protocol::protocol::v2::{ - Turn, TurnCompletedNotification, TurnItemsView, TurnStatus, - }; - - let mut app = App::default(); - app.start_turn("turn-1".to_string()); - app.apply_notification(ServerNotification::TurnCompleted( - TurnCompletedNotification { - thread_id: "thread-1".to_string(), - turn: Turn { - id: "turn-1".to_string(), - items: Vec::new(), - items_view: TurnItemsView::Full, - status: TurnStatus::Completed, - error: None, - started_at: Some(1), - completed_at: Some(2), - duration_ms: Some(1), - }, - }, - )); - - assert!(app.projection.active_turn_id().is_none()); - assert!(app.active_turn_elapsed(Instant::now()).is_none()); - } - - #[test] - fn permission_profile_catalog_is_trimmed_deduplicated_and_used_for_cycles() { - let mut app = App::default(); - app.set_permission_profiles([ - " custom-read ".to_string(), - "custom-write".to_string(), - "custom-read".to_string(), - "".to_string(), - ]); - - assert_eq!( - app.permission_profiles, - vec!["custom-read".to_string(), "custom-write".to_string()] - ); - assert_eq!( - app.cycle_permission_profile(Some("custom-read"), 1), - "custom-write" - ); - assert_eq!( - app.cycle_permission_profile(Some("custom-write"), 1), - "custom-read" - ); - - app.set_permission_profiles([" ".to_string(), "custom-read".to_string()]); - assert_eq!(app.permission_profiles, vec!["custom-read".to_string()]); - app.set_permission_profiles(std::iter::empty::()); - assert!(app.permission_profiles.is_empty()); - assert_eq!( - app.cycle_permission_profile(Some(":read-only"), 1), - ":workspace" - ); - } - - #[test] - fn an_open_popup_owns_escape_before_active_turn_interruption() { - let mut app = App::default(); - app.start_turn("turn-1".to_string()); - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('/'), - KeyModifiers::NONE, - ))); - assert!(app.command_popup.is_some()); - - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))), - AppAction::None - ); - assert!(app.command_popup.is_none()); - assert!(app.projection.active_turn_id().is_some()); - } - - #[test] - fn history_search_owns_escape_before_active_turn_interruption() { - let mut app = App::default(); - app.composer.load_history(["previous prompt".to_string()]); - app.composer.insert("previous"); - app.start_turn("turn-1".to_string()); - - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('r'), - KeyModifiers::CONTROL, - ))); - assert!(app.composer.history_search_active()); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,))), - AppAction::None - ); - assert!(!app.composer.history_search_active()); - assert_eq!(app.composer.text(), "previous"); - assert!(app.projection.active_turn_id().is_some()); - } - - #[test] - fn codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft() { - let mut app = App::default(); - app.composer.insert("draft"); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('.'), - KeyModifiers::ALT, - ))), - AppAction::IncreaseEffort - ); - assert_eq!( - app.handle_terminal_event(Event::Key( - KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE,) - )), - AppAction::NextPermissions - ); - assert_eq!(app.composer.text(), "draft"); - } - - #[test] - fn copy_shortcut_and_slash_command_do_not_become_turn_input() { - let mut app = App::default(); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('o'), - KeyModifiers::CONTROL, - ))), - AppAction::CopyLastResponse - ); - app.composer.insert("/copy"); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::CopyLastResponse - ); - assert!(app.composer.is_empty()); - } - - #[test] - fn slash_popup_filters_and_executes_immediate_commands() { - let mut app = App::default(); - for character in ['/', 'm'] { - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char(character), - KeyModifiers::NONE, - ))), - AppAction::None - ); - } - assert_eq!( - app.command_popup.as_ref().and_then(CommandPopup::selected), - Some(SlashCommand::Model) - ); - - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::Submit("/model".to_string()) - ); - assert!(app.command_popup.is_none()); - assert!(app.composer.is_empty()); - } - - #[test] - fn slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes() { - let mut app = App::default(); - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('/'), - KeyModifiers::NONE, - ))); - assert!(app.command_popup.is_some()); - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))); - assert!(app.command_popup.is_none()); - - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('e'), - KeyModifiers::NONE, - ))); - assert_eq!( - app.command_popup.as_ref().and_then(CommandPopup::selected), - Some(SlashCommand::Effort) - ); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::None - ); - assert_eq!(app.composer.text(), "/effort "); - assert!(app.command_popup.is_none()); - } - - #[test] - fn status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed() { - let mut app = App::default(); - app.composer.insert("real prompt"); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::Submit("real prompt".to_string()) - ); - app.set_thread_id("thread-1".to_string()); - app.set_settings( - Some("gpt-5".to_string()), - Some("openai".to_string()), - Some("high".to_string()), - Some(":workspace".to_string()), - ); - app.composer.insert("/status"); - - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::None - ); - assert!(app.pager_overlay.is_some()); - assert!(app.composer.is_empty()); - - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('x'), - KeyModifiers::NONE, - ))); - assert!(app.composer.is_empty()); - assert!(app.pager_overlay.is_some()); - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('q'), - KeyModifiers::NONE, - ))); - assert!(app.pager_overlay.is_none()); - - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))); - assert_eq!(app.composer.text(), "real prompt"); - assert!(app.projection.entries().is_empty()); - } - - #[test] - fn ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state() { - let mut app = App::default(); - app.composer.insert("draft"); - - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('t'), - KeyModifiers::CONTROL, - ))), - AppAction::None - ); - assert!( - app.pager_overlay - .as_ref() - .is_some_and(PagerOverlay::is_transcript) - ); - assert_eq!(app.composer.text(), "draft"); - assert!(app.projection.entries().is_empty()); - - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('t'), - KeyModifiers::CONTROL, - ))); - assert!(app.pager_overlay.is_none()); - assert_eq!(app.composer.text(), "draft"); - } - - #[test] - fn image_shortcut_attaches_and_allows_image_only_submission() { - let mut app = App::default(); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Char('v'), - KeyModifiers::CONTROL, - ))), - AppAction::PasteImage - ); - app.attach_image(PathBuf::from("/tmp/input.png")); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new( - KeyCode::Enter, - KeyModifiers::NONE, - ))), - AppAction::Submit(String::new()) - ); - assert_eq!( - app.take_pending_images(), - vec![PathBuf::from("/tmp/input.png")] - ); - } - - #[test] - fn failed_image_submission_can_restore_pending_attachments() { - let mut app = App::default(); - app.attach_image(PathBuf::from("/tmp/one.png")); - app.attach_image(PathBuf::from("/tmp/two.png")); - - let images = app.take_pending_images(); - assert!(app.pending_images.is_empty()); - app.restore_pending_images(images); - - assert_eq!( - app.pending_images, - vec![PathBuf::from("/tmp/one.png"), PathBuf::from("/tmp/two.png")] - ); - } - - #[test] - fn queued_submission_projection_updates_by_id_and_clears_on_thread_change() { - let queued = |id: &str, text: &str| QueuedSubmission { - id: id.to_string(), - input: vec![UserInput::Text { - text: text.to_string(), - text_elements: Vec::new(), - }], - client_user_message_id: format!("client-{id}"), - }; - let mut app = App::default(); - app.set_thread_id("thread-1".to_string()); - app.upsert_queued_submission(queued("queue-1", "first")); - app.upsert_queued_submission(queued("queue-1", "revised")); - app.upsert_queued_submission(queued("queue-2", "second")); - - assert_eq!(app.queued_submissions.len(), 2); - assert!(matches!( - app.queued_submissions[0].input.as_slice(), - [UserInput::Text { text, .. }] if text == "revised" - )); - - app.set_thread_id("thread-2".to_string()); - assert!(app.queued_submissions.is_empty()); - } - - #[test] - fn alt_up_requests_server_delete_before_restoring_the_last_queued_input() { - let submission = QueuedSubmission { - id: "queue-1".to_string(), - input: vec![ - UserInput::LocalImage { - detail: None, - path: "/tmp/queued.png".to_string(), - }, - UserInput::Text { - text: "revise this follow-up".to_string(), - text_elements: Vec::new(), - }, - ], - client_user_message_id: "client-queue-1".to_string(), - }; - let mut app = App::default(); - app.set_queued_submissions(vec![submission.clone()]); - - let action = - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT))); - - assert_eq!(action, AppAction::EditQueuedSubmission(submission.clone())); - assert_eq!(app.queued_submissions, vec![submission.clone()]); - assert!(app.composer.is_empty()); - assert!(app.pending_images.is_empty()); - - assert!(app.restore_queued_submission_for_edit(submission)); - assert!(app.queued_submissions.is_empty()); - assert_eq!(app.composer.text(), "revise this follow-up"); - assert_eq!(app.pending_images, vec![PathBuf::from("/tmp/queued.png")]); - } - - #[test] - fn alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit() { - let remote_image = QueuedSubmission { - id: "queue-remote".to_string(), - input: vec![UserInput::Image { - detail: None, - url: "https://example.test/input.png".to_string(), - }], - client_user_message_id: "client-remote".to_string(), - }; - let mut app = App::default(); - app.set_queued_submissions(vec![remote_image]); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,))), - AppAction::None - ); - - app.set_queued_submissions(vec![QueuedSubmission { - id: "queue-text".to_string(), - input: vec![UserInput::Text { - text: "queued".to_string(), - text_elements: Vec::new(), - }], - client_user_message_id: "client-text".to_string(), - }]); - app.composer.insert("unsent draft"); - assert_eq!( - app.handle_terminal_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,))), - AppAction::None - ); - assert_eq!(app.composer.text(), "unsent draft"); - assert_eq!(app.queued_submissions.len(), 1); - } -} +mod tests; diff --git a/lime-rs/crates/tui/src/app/agent_navigation.rs b/lime-rs/crates/tui/src/app/agent_navigation.rs new file mode 100644 index 000000000..031d63033 --- /dev/null +++ b/lime-rs/crates/tui/src/app/agent_navigation.rs @@ -0,0 +1,344 @@ +//! Multi-agent picker navigation and labeling state for the TUI app. +//! +//! This module mirrors Codex's `app/agent_navigation.rs` boundary. It owns only stable ordering, +//! labels, and liveness facts derived from App Server `Thread`/`SubAgentActivity` data. Thread +//! lifecycle and switching remain in `App` and `AppServerSession`. + +use crate::multi_agents::{ + format_agent_picker_item_name, next_agent_shortcut, previous_agent_shortcut, + AgentPickerThreadEntry, SubAgentActivityDisplay, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentNavigationDirection { + Previous, + Next, +} + +#[derive(Debug, Default)] +pub(crate) struct AgentNavigationState { + threads: std::collections::HashMap, + order: Vec, + stopped_threads: std::collections::HashSet, + parent_owned_threads: std::collections::HashSet, +} + +#[allow(dead_code)] +impl AgentNavigationState { + pub(crate) fn get(&self, thread_id: &str) -> Option<&AgentPickerThreadEntry> { + self.threads.get(thread_id) + } + + pub(crate) fn is_empty(&self) -> bool { + self.threads.is_empty() + } + + pub(crate) fn is_parent_owned(&self, thread_id: &str) -> bool { + self.parent_owned_threads.contains(thread_id) + } + + pub(crate) fn mark_parent_owned(&mut self, thread_id: impl Into) { + self.parent_owned_threads.insert(thread_id.into()); + } + + pub(crate) fn upsert( + &mut self, + thread_id: impl Into, + agent_nickname: Option, + agent_role: Option, + is_closed: bool, + ) { + let thread_id = thread_id.into(); + if !self.threads.contains_key(&thread_id) { + self.order.push(thread_id.clone()); + } + let (agent_path, is_running) = self + .threads + .get(&thread_id) + .map(|entry| (entry.agent_path.clone(), entry.is_running)) + .unwrap_or((None, false)); + self.threads.insert( + thread_id, + AgentPickerThreadEntry { + agent_nickname, + agent_role, + agent_path, + is_running: is_running && !is_closed, + is_closed, + }, + ); + } + + pub(crate) fn record_sub_agent_activity(&mut self, activity: SubAgentActivityDisplay) { + if !self.threads.contains_key(&activity.thread_id) { + self.order.push(activity.thread_id.clone()); + } + let entry = self + .threads + .entry(activity.thread_id.clone()) + .or_insert_with(|| AgentPickerThreadEntry { + agent_nickname: None, + agent_role: None, + agent_path: None, + is_running: false, + is_closed: false, + }); + entry.agent_path = Some(activity.agent_path); + if activity.is_running_hint + && !entry.is_closed + && !self.stopped_threads.contains(&activity.thread_id) + { + entry.is_running = true; + } else { + entry.is_running = false; + self.stopped_threads.insert(activity.thread_id); + } + } + + pub(crate) fn mark_running(&mut self, thread_id: &str) { + if self + .threads + .get(thread_id) + .is_some_and(|entry| entry.is_closed) + { + return; + } + self.stopped_threads.remove(thread_id); + self.set_running(thread_id, true); + } + + pub(crate) fn mark_stopped(&mut self, thread_id: &str) { + self.stopped_threads.insert(thread_id.to_string()); + self.set_running(thread_id, false); + } + + pub(crate) fn set_running(&mut self, thread_id: &str, is_running: bool) { + if let Some(entry) = self.threads.get_mut(thread_id) { + entry.is_running = is_running; + } + } + + pub(crate) fn set_agent_path(&mut self, thread_id: &str, agent_path: Option) { + if let Some(agent_path) = agent_path { + if let Some(entry) = self.threads.get_mut(thread_id) { + entry.agent_path = Some(agent_path); + } + } + } + + pub(crate) fn mark_closed(&mut self, thread_id: &str) { + if let Some(entry) = self.threads.get_mut(thread_id) { + entry.is_closed = true; + entry.is_running = false; + } else { + self.upsert(thread_id, None, None, true); + } + } + + pub(crate) fn clear(&mut self) { + self.threads.clear(); + self.order.clear(); + self.stopped_threads.clear(); + self.parent_owned_threads.clear(); + } + + pub(crate) fn remove(&mut self, thread_id: &str) { + self.threads.remove(thread_id); + self.order.retain(|candidate| candidate != thread_id); + self.stopped_threads.remove(thread_id); + self.parent_owned_threads.remove(thread_id); + } + + pub(crate) fn ordered_threads(&self) -> Vec<(&str, &AgentPickerThreadEntry)> { + self.order + .iter() + .filter_map(|thread_id| { + self.threads + .get(thread_id) + .map(|entry| (thread_id.as_str(), entry)) + }) + .collect() + } + + pub(crate) fn ordered_path_backed_subagent_threads( + &self, + primary_thread_id: Option<&str>, + ) -> Vec<(&str, &AgentPickerThreadEntry)> { + self.ordered_threads() + .into_iter() + .filter(|(thread_id, entry)| { + Some(*thread_id) != primary_thread_id + && entry + .agent_path + .as_deref() + .is_some_and(|path| !path.trim().is_empty()) + }) + .collect() + } + + pub(crate) fn tracked_thread_ids(&self) -> Vec { + self.order + .iter() + .filter(|thread_id| self.threads.contains_key(*thread_id)) + .cloned() + .collect() + } + + pub(crate) fn adjacent_thread_id( + &self, + current_displayed_thread_id: Option<&str>, + direction: AgentNavigationDirection, + ) -> Option { + let ordered = self.tracked_thread_ids(); + if ordered.len() < 2 { + return None; + } + let current = current_displayed_thread_id?; + let index = ordered.iter().position(|thread_id| thread_id == current)?; + let next = match direction { + AgentNavigationDirection::Next => (index + 1) % ordered.len(), + AgentNavigationDirection::Previous => index.checked_sub(1).unwrap_or(ordered.len() - 1), + }; + Some(ordered[next].clone()) + } + + pub(crate) fn active_agent_label( + &self, + current_displayed_thread_id: Option<&str>, + primary_thread_id: Option<&str>, + ) -> Option { + if self.threads.len() <= 1 { + return None; + } + let thread_id = current_displayed_thread_id?; + let is_primary = primary_thread_id == Some(thread_id); + Some( + self.threads + .get(thread_id) + .map(|entry| { + if !is_primary { + if let Some(path) = entry + .agent_path + .as_deref() + .filter(|path| !path.trim().is_empty()) + { + return format!("`{path}`"); + } + } + format_agent_picker_item_name( + entry.agent_nickname.as_deref(), + entry.agent_role.as_deref(), + is_primary, + ) + }) + .unwrap_or_else(|| format_agent_picker_item_name(None, None, is_primary)), + ) + } + + pub(crate) fn picker_subtitle() -> String { + format!( + "Select an agent to watch. Alt+{} previous, Alt+{} next.", + key_label(previous_agent_shortcut()), + key_label(next_agent_shortcut()) + ) + } +} + +fn key_label(key: crossterm::event::KeyCode) -> &'static str { + match key { + crossterm::event::KeyCode::Left => "Left", + crossterm::event::KeyCode::Right => "Right", + _ => "key", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn populated_state() -> (AgentNavigationState, String, String, String) { + let mut state = AgentNavigationState::default(); + let main = "main".to_string(); + let first = "agent-1".to_string(); + let second = "agent-2".to_string(); + state.upsert(main.clone(), None, None, false); + state.upsert( + first.clone(), + Some("Robie".into()), + Some("explorer".into()), + false, + ); + state.upsert( + second.clone(), + Some("Bob".into()), + Some("worker".into()), + false, + ); + (state, main, first, second) + } + + #[test] + fn upsert_preserves_first_seen_order() { + let (mut state, main, first, second) = populated_state(); + state.upsert( + first.clone(), + Some("Robie".into()), + Some("worker".into()), + true, + ); + assert_eq!(state.tracked_thread_ids(), vec![main, first, second]); + } + + #[test] + fn adjacent_thread_id_wraps_in_spawn_order() { + let (state, main, first, second) = populated_state(); + assert_eq!( + state.adjacent_thread_id(Some(second.as_str()), AgentNavigationDirection::Next), + Some(main.clone()) + ); + assert_eq!( + state.adjacent_thread_id(Some(main.as_str()), AgentNavigationDirection::Previous), + Some(second.clone()) + ); + assert_eq!( + state.adjacent_thread_id(Some(second.as_str()), AgentNavigationDirection::Previous), + Some(first) + ); + } + + #[test] + fn active_agent_label_tracks_current_thread() { + let (state, main, first, _) = populated_state(); + assert_eq!( + state.active_agent_label(Some(first.as_str()), Some(main.as_str())), + Some("Robie [explorer]".to_string()) + ); + assert_eq!( + state.active_agent_label(Some(main.as_str()), Some(main.as_str())), + Some("Main [default]".to_string()) + ); + } + + #[test] + fn stopped_activity_does_not_revive_after_liveness_terminal_state() { + let mut state = AgentNavigationState::default(); + state.record_sub_agent_activity(SubAgentActivityDisplay { + thread_id: "agent-1".into(), + agent_path: "/root/child".into(), + is_running_hint: false, + }); + state.record_sub_agent_activity(SubAgentActivityDisplay { + thread_id: "agent-1".into(), + agent_path: "/root/child".into(), + is_running_hint: true, + }); + assert!(!state.get("agent-1").expect("tracked").is_running); + } + + #[test] + fn picker_subtitle_mentions_codex_shortcuts() { + let subtitle = AgentNavigationState::picker_subtitle(); + assert!(subtitle.contains("Alt+Left")); + assert!(subtitle.contains("Alt+Right")); + } +} diff --git a/lime-rs/crates/tui/src/app/agent_picker.rs b/lime-rs/crates/tui/src/app/agent_picker.rs new file mode 100644 index 000000000..a49dd8aa1 --- /dev/null +++ b/lime-rs/crates/tui/src/app/agent_picker.rs @@ -0,0 +1,279 @@ +//! Agent picker UI matching Codex's `/subagents` interaction shape. +//! +//! Rows are snapshots of the App Server-backed navigation state. Selecting a row only returns a +//! thread id; `App` and `AppServerSession` own the actual `thread/resume` request. + +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +use super::agent_navigation::AgentNavigationState; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::locale::Locale; +use crate::multi_agents::format_agent_picker_item_name; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AgentPickerEntry { + thread_id: String, + label: String, + is_running: bool, + is_closed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum AgentPickerAction { + None, + Cancel, + Select(String), +} + +#[derive(Debug, Clone)] +pub(crate) struct AgentPicker { + entries: Vec, + selected: usize, +} + +impl AgentPicker { + #[allow(dead_code)] + pub(crate) fn from_navigation( + navigation: &AgentNavigationState, + primary_thread_id: Option<&str>, + ) -> Self { + let entries = navigation + .ordered_threads() + .into_iter() + .map(|(thread_id, entry)| { + let is_primary = primary_thread_id == Some(thread_id); + let label = format_agent_picker_item_name( + entry.agent_nickname.as_deref(), + entry.agent_role.as_deref(), + is_primary, + ); + AgentPickerEntry { + thread_id: thread_id.to_string(), + label, + is_running: entry.is_running, + is_closed: entry.is_closed, + } + }) + .collect(); + Self { + entries, + selected: 0, + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn handle_event(&mut self, event: Event) -> AgentPickerAction { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + return AgentPickerAction::Cancel; + } + match key.code { + KeyCode::Esc => AgentPickerAction::Cancel, + KeyCode::Up | KeyCode::Char('k') => { + self.selected = self.selected.saturating_sub(1); + AgentPickerAction::None + } + KeyCode::Down | KeyCode::Char('j') => { + if !self.entries.is_empty() { + self.selected = (self.selected + 1).min(self.entries.len() - 1); + } + AgentPickerAction::None + } + KeyCode::Enter => self + .entries + .get(self.selected) + .map(|entry| AgentPickerAction::Select(entry.thread_id.clone())) + .unwrap_or(AgentPickerAction::None), + _ => AgentPickerAction::None, + } + } + _ => AgentPickerAction::None, + } + } + + fn rows(&self) -> impl Iterator { + self.entries.iter() + } +} + +pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, locale: Locale) { + let width = area.width.saturating_mul(4).saturating_div(5).clamp(32, 72); + let height = area.height.saturating_mul(3).saturating_div(4).clamp(7, 18); + let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); + let y = area + .y + .saturating_add(area.height.saturating_sub(height) / 2); + let popup = Rect::new(x, y, width.min(area.width), height.min(area.height)); + + frame.render_widget(Clear, popup); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(1), + Constraint::Length(1), + ]) + .split(popup); + frame.render_widget( + Paragraph::new(truncate_line_with_ellipsis_if_overflow( + Line::from(vec![Span::styled( + format!(" {} ", locale.agent_picker_title()), + Style::default().add_modifier(Modifier::BOLD), + )]), + usize::from(chunks[0].width.saturating_sub(2)), + )) + .block(Block::default().borders(Borders::TOP)), + chunks[0], + ); + + let items = picker + .rows() + .map(|entry| { + let status = if entry.is_closed { + format!(" [{}]", locale.status("closed")) + } else if entry.is_running { + format!(" [{}]", locale.status("running")) + } else { + String::new() + }; + ListItem::new(truncate_line_with_ellipsis_if_overflow( + Line::from(format!("{}{}", entry.label, status)), + usize::from(chunks[1].width.saturating_sub(2)), + )) + }) + .collect::>(); + let mut state = ListState::default(); + if !items.is_empty() { + state.select(Some(picker.selected)); + } + frame.render_stateful_widget( + List::new(items) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "), + chunks[1], + &mut state, + ); + let footer = if picker.is_empty() { + locale.agent_picker_empty() + } else { + locale.agent_picker_footer() + }; + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + footer, + Style::default().fg(Color::DarkGray), + ))), + chunks[2], + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + fn picker() -> AgentPicker { + let mut navigation = AgentNavigationState::default(); + navigation.upsert("main", None, None, false); + navigation.upsert( + "agent-1", + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + AgentPicker::from_navigation(&navigation, Some("main")) + } + + #[test] + fn picker_selects_rows_in_stable_navigation_order() { + let mut picker = picker(); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Down, + KeyModifiers::NONE, + ))), + AgentPickerAction::None + ); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentPickerAction::Select("agent-1".to_string()) + ); + } + + #[test] + fn picker_escape_cancels_and_narrow_render_stays_bounded() { + let mut picker = picker(); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Esc, + KeyModifiers::NONE, + ))), + AgentPickerAction::Cancel + ); + let mut terminal = Terminal::new(TestBackend::new(16, 7)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &picker, Locale::EnUs)) + .expect("draw"); + } + + #[test] + fn picker_copy_and_status_render_for_every_product_locale() { + let picker = picker(); + let locales = [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ]; + for locale in locales { + let mut terminal = Terminal::new(TestBackend::new(64, 12)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &picker, locale)) + .expect("draw"); + let buffer = terminal.backend().buffer(); + let text = (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + let compact = |value: &str| { + value + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + }; + assert!( + compact(&text).contains(&compact(locale.agent_picker_title())), + "locale={locale:?} text={text:?}" + ); + assert!( + compact(&text).contains(&compact(locale.agent_picker_footer())), + "locale={locale:?} text={text:?}" + ); + } + } +} diff --git a/lime-rs/crates/tui/src/app/agents_overview.rs b/lime-rs/crates/tui/src/app/agents_overview.rs new file mode 100644 index 000000000..c11de86be --- /dev/null +++ b/lime-rs/crates/tui/src/app/agents_overview.rs @@ -0,0 +1,327 @@ +//! Codex-shaped Agents Overview state and canonical thread projection. + +use super::agents_overview_view::{AgentsOverviewGroup, AgentsOverviewRow, AgentsOverviewView}; +use crate::app_server_session::AppServerSession; +use anyhow::{anyhow, Result}; +use app_server_protocol::protocol::v2::ServerNotification; +use app_server_protocol::protocol::v2::{Thread, TurnStatus, UserInput}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tokio::task::AbortHandle; + +#[allow(dead_code)] +pub(crate) const AGENTS_OVERVIEW_VIEW_ID: &str = "agents-overview"; + +#[derive(Debug, Default)] +pub(crate) struct AgentsOverviewState { + pub(crate) threads: Vec, + /// True after the first server-backed seed has completed. + pub(crate) initialized: bool, + /// Monotonic refresh identity. A response from an older request is ignored. + pub(crate) request_id: Option, + /// A refresh requested while another refresh is in flight is coalesced. + pub(crate) refresh_pending: bool, + /// Thread ids with metadata changes that should be refreshed on the next pass. + pub(crate) refresh_thread_ids: HashSet, + /// Abort handle for a background refresh task, when one is installed by a host. + pub(crate) refresh_task: Option, + /// Notifications received during a refresh, keyed by thread and replayed afterward. + pub(crate) refresh_notifications: HashMap>, + /// Whether the overview is currently rendered as the primary full-screen surface. + pub(crate) rendered_full_screen: bool, + /// Canonical order of rows currently visible after filtering/grouping. + pub(crate) visible_thread_ids: Vec, + /// Codex-compatible shared view state for hosts that render through a selection surface. + /// `view` remains the Lime terminal owner; this mirror lets adapters share the same shape + /// without introducing a second interaction model. + pub(crate) view_state: Arc>, + /// Drafts are retained per thread while the command center is open or a root is resumed. + pub(crate) input_states: HashMap, + pub(crate) view: AgentsOverviewView, + pub(crate) refreshing: bool, + pub(crate) refresh_generation: u64, +} + +impl AgentsOverviewState { + pub(crate) fn new(primary_thread_id: Option<&str>) -> Self { + let view = AgentsOverviewView::new(Vec::new(), primary_thread_id); + Self { + threads: Vec::new(), + initialized: false, + request_id: None, + refresh_pending: false, + refresh_thread_ids: HashSet::new(), + refresh_task: None, + refresh_notifications: HashMap::new(), + rendered_full_screen: false, + visible_thread_ids: Vec::new(), + view_state: Arc::new(Mutex::new(view.clone())), + input_states: HashMap::new(), + view, + refreshing: false, + refresh_generation: 0, + } + } + + pub(crate) fn begin_refresh(&mut self) -> u64 { + if self.refreshing { + self.refresh_pending = true; + return self.refresh_generation; + } + self.refresh_generation = self.refresh_generation.wrapping_add(1); + self.refreshing = true; + self.request_id = Some(self.refresh_generation); + self.refresh_generation + } + + pub(crate) fn replace_threads( + &mut self, + threads: Vec, + primary_thread_id: Option<&str>, + ) { + // App Server's recent index is a seed, not an eviction list. Keep locally observed + // sessions until an explicit archive/delete notification removes them. + let mut by_id = self + .threads + .drain(..) + .map(|thread| (thread.id.clone(), thread)) + .collect::>(); + for thread in threads { + by_id.insert(thread.id.clone(), thread); + } + self.threads = by_id.into_values().collect(); + let rows = build_rows(&self.threads, primary_thread_id); + self.view.update_rows(rows); + self.visible_thread_ids = self + .view + .visible_rows() + .into_iter() + .map(|row| row.thread.id.clone()) + .collect(); + self.sync_view_state(); + self.refreshing = false; + self.initialized = true; + self.request_id = None; + self.refresh_thread_ids.clear(); + } + + pub(crate) fn apply_refresh( + &mut self, + generation: u64, + threads: Vec, + primary_thread_id: Option<&str>, + ) -> bool { + if generation != self.refresh_generation { + return false; + } + self.replace_threads(threads, primary_thread_id); + true + } + + pub(crate) fn sync_view_state(&self) { + if let Ok(mut state) = self.view_state.lock() { + *state = self.view.clone(); + } + } + + pub(crate) fn take_refresh_pending(&mut self) -> bool { + std::mem::take(&mut self.refresh_pending) + } +} + +impl Drop for AgentsOverviewState { + fn drop(&mut self) { + if let Some(handle) = self.refresh_task.take() { + handle.abort(); + } + } +} + +pub(crate) fn build_rows( + threads: &[Thread], + primary_thread_id: Option<&str>, +) -> Vec { + let mut by_id = HashMap::new(); + for thread in threads.iter().filter(|thread| !thread.ephemeral) { + by_id.insert(thread.id.clone(), thread); + } + let mut children: HashMap<&str, Vec<&Thread>> = HashMap::new(); + for thread in by_id.values() { + if let Some(parent) = thread.parent_thread_id.as_deref() { + children.entry(parent).or_default().push(thread); + } + } + let mut rows = by_id + .values() + .filter(|thread| thread.parent_thread_id.is_none()) + .map(|thread| AgentsOverviewRow { + thread: (*thread).clone(), + group: agents_overview_group(thread, &children), + is_current: primary_thread_id == Some(thread.id.as_str()), + }) + .collect::>(); + if rows.is_empty() { + rows = by_id + .values() + .map(|thread| AgentsOverviewRow { + thread: (*thread).clone(), + group: AgentsOverviewGroup::for_status(&thread.status), + is_current: primary_thread_id == Some(thread.id.as_str()), + }) + .collect(); + } + rows.sort_by(|left, right| { + left.group + .cmp(&right.group) + .then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at)) + .then_with(|| left.thread.id.cmp(&right.thread.id)) + }); + rows +} + +pub(crate) fn agents_overview_group( + thread: &Thread, + children: &HashMap<&str, Vec<&Thread>>, +) -> AgentsOverviewGroup { + children.get(thread.id.as_str()).into_iter().flatten().fold( + AgentsOverviewGroup::for_status(&thread.status), + |group, child| group.min(agents_overview_group(child, children)), + ) +} + +impl super::App { + pub(crate) fn open_agents_overview(&mut self) { + let primary = self.primary_thread_id.as_deref(); + let mut overview = AgentsOverviewState::new(primary); + overview.rendered_full_screen = true; + self.agents_overview = Some(overview); + if !self.composer.text().trim_start().starts_with("/subagents") { + self.capture_current_thread_input(); + } + } + + #[allow(dead_code)] + pub(crate) fn apply_agents_overview_thread_refresh( + &mut self, + generation: u64, + result: Result, String>, + ) -> bool { + let primary = self.primary_thread_id.clone(); + let Some(overview) = self.agents_overview.as_mut() else { + return false; + }; + match result { + Ok(threads) => overview.apply_refresh(generation, threads, primary.as_deref()), + Err(error) => { + overview.refreshing = false; + overview.request_id = None; + self.projection + .set_status(format!("agents overview unavailable: {error}")); + false + } + } + } + + #[allow(dead_code)] + pub(crate) fn select_agents_overview_thread(&self) -> Option { + self.agents_overview + .as_ref() + .and_then(|overview| overview.view.selected_thread_id().map(str::to_owned)) + } + + #[allow(dead_code)] + pub(crate) fn agents_overview_view( + &self, + threads: Vec, + selected_thread_id: Option<&str>, + ) -> AgentsOverviewView { + let rows = build_rows(&threads, self.primary_thread_id.as_deref()); + AgentsOverviewView::new(rows, selected_thread_id) + } + + #[allow(dead_code)] + pub(crate) fn repaint_agents_overview(&mut self) { + let primary = self.primary_thread_id.clone(); + if let Some(overview) = self.agents_overview.as_mut() { + overview + .view + .update_rows(build_rows(&overview.threads, primary.as_deref())); + overview.visible_thread_ids = overview + .view + .visible_rows() + .into_iter() + .map(|row| row.thread.id.clone()) + .collect(); + overview.sync_view_state(); + } + } + + /// Start a background task through the current App Server session. + pub(crate) async fn dispatch_agents_overview_task( + &self, + app_server: &AppServerSession, + prompt: String, + cwd: Option, + ) -> Result<(String, String)> { + let thread = app_server + .start_thread_with_session_start_source( + cwd.unwrap_or_else(|| self.cwd.clone()), + self.model.clone(), + self.model_provider.clone(), + None, + ) + .await?; + let thread_id = thread.thread.id.clone(); + let turn_id = self + .submit_agents_overview_prompt(app_server, thread_id.clone(), prompt) + .await?; + Ok((thread_id, turn_id)) + } + + pub(crate) async fn submit_agents_overview_prompt( + &self, + app_server: &AppServerSession, + thread_id: String, + prompt: String, + ) -> Result { + if prompt.trim().is_empty() { + return Err(anyhow!("background task prompt must not be empty")); + } + app_server + .turn_start( + thread_id, + vec![UserInput::Text { + text: prompt, + text_elements: Vec::new(), + }], + ) + .await + } + + pub(crate) async fn stop_agents_overview_thread( + &self, + app_server: &AppServerSession, + thread_id: String, + ) -> Result> { + let thread = app_server.thread_read(thread_id.clone(), true).await?; + let turn_id = thread + .thread + .turns + .into_iter() + .rev() + .find(|turn| turn.status == TurnStatus::InProgress) + .map(|turn| turn.id); + let Some(turn_id) = turn_id else { + return Ok(None); + }; + app_server + .turn_interrupt(thread_id, turn_id.clone()) + .await?; + Ok(Some(turn_id)) + } +} + +#[cfg(test)] +#[path = "agents_overview_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/app/agents_overview_render.rs b/lime-rs/crates/tui/src/app/agents_overview_render.rs new file mode 100644 index 000000000..0c3bbbe4e --- /dev/null +++ b/lime-rs/crates/tui/src/app/agents_overview_render.rs @@ -0,0 +1,322 @@ +//! Ratatui rendering for the Codex-shaped Agents Overview. + +use super::{AgentsOverviewGroup, AgentsOverviewInputMode, AgentsOverviewView}; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::locale::Locale; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewView, locale: Locale) { + let width = area + .width + .saturating_mul(4) + .saturating_div(5) + .clamp(48, 100); + let height = area + .height + .saturating_mul(4) + .saturating_div(5) + .clamp(10, 26); + let popup = Rect::new( + area.x.saturating_add(area.width.saturating_sub(width) / 2), + area.y + .saturating_add(area.height.saturating_sub(height) / 2), + width.min(area.width), + height.min(area.height), + ); + if popup.width < 20 || popup.height < 8 { + return; + } + frame.render_widget(Clear, popup); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Min(4), + Constraint::Length(1), + ]) + .split(popup); + frame.render_widget( + Paragraph::new(Line::from(Span::styled( + format!(" {}", locale.agents_overview_title()), + Style::default().add_modifier(Modifier::BOLD), + ))) + .block(Block::default().borders(Borders::TOP)), + chunks[0], + ); + let mut counts = [0usize; 4]; + for row in view.visible_rows() { + counts[row.group as usize] += 1; + } + let summary_text = format!( + "{} {} {} {} {} {} {} {}", + counts[0], + locale.agents_overview_group_label("need input"), + counts[1], + locale.agents_overview_group_label("working"), + counts[2], + locale.agents_overview_group_label("ready"), + counts[3], + locale.agents_overview_group_label("finished") + ); + frame.render_widget( + Paragraph::new(Span::styled( + summary_text, + Style::default().fg(Color::DarkGray), + )), + chunks[1], + ); + + let body = chunks[2]; + let (list_area, details_area) = if body.width >= 90 { + let layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Min(44), + Constraint::Length(1), + Constraint::Length(34), + ]) + .split(body); + (layout[0], Some(layout[2])) + } else { + (body, None) + }; + let items = view + .visible_rows() + .into_iter() + .map(|row| { + let marker = match row.group { + AgentsOverviewGroup::NeedsYou => "●", + AgentsOverviewGroup::Working => "●", + AgentsOverviewGroup::Ready => "○", + AgentsOverviewGroup::Finished => "✓", + }; + let title = row + .thread + .name + .as_deref() + .or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str())) + .unwrap_or("Untitled task"); + let current = if row.is_current { " current" } else { "" }; + let project = if view.status_grouping() { + String::new() + } else { + format!( + " {} · {}", + locale.agents_overview_group_label(row.group.label()), + row.thread.cwd.display() + ) + }; + ListItem::new(truncate_line_with_ellipsis_if_overflow( + Line::from(vec![ + Span::styled(format!("{marker} "), Style::default().fg(Color::Cyan)), + Span::raw(title.to_string()), + Span::styled(current, Style::default().fg(Color::DarkGray)), + Span::styled(project, Style::default().fg(Color::DarkGray)), + ]), + usize::from(list_area.width.saturating_sub(4)), + )) + }) + .collect::>(); + let mut state = ListState::default(); + if let Some(selected) = view.selected_index() { + state.select(Some(selected.min(items.len().saturating_sub(1)))); + } + frame.render_stateful_widget( + List::new(items) + .block(Block::default().borders(Borders::LEFT | Borders::RIGHT)) + .highlight_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("> "), + list_area, + &mut state, + ); + if let Some(details_area) = details_area { + render_details(frame, details_area, view, locale); + } + let footer_text = if let Some(mode) = view.input_mode() { + format!( + "{}: {} · enter confirm · esc cancel", + locale.agents_overview_input_prefix(matches!(mode, AgentsOverviewInputMode::Rename)), + view.input() + ) + } else if view.is_searching() { + format!( + "{}: {} · enter open · esc back", + locale.agents_overview_search_prefix(), + view.search() + ) + } else { + locale.agents_overview_footer().to_string() + }; + frame.render_widget( + Paragraph::new(Span::styled( + footer_text, + Style::default().fg(Color::DarkGray), + )), + chunks[3], + ); +} + +fn render_details(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewView, locale: Locale) { + let Some(row) = view.selected_row() else { + return; + }; + let title = row + .thread + .name + .as_deref() + .filter(|name| !name.trim().is_empty()) + .or_else(|| (!row.thread.preview.is_empty()).then_some(row.thread.preview.as_str())) + .unwrap_or("Untitled task"); + let status = locale.agents_overview_group_label(row.group.label()); + let mut lines = vec![ + Line::from(Span::styled( + "Task details", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::default(), + Line::from(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(vec![ + Span::styled(status, Style::default().fg(Color::Cyan)), + Span::styled( + if row.is_current { " current" } else { "" }, + Style::default().fg(Color::DarkGray), + ), + ]), + Line::default(), + Line::from(Span::styled( + "Project", + Style::default().fg(Color::DarkGray), + )), + Line::from(row.thread.cwd.display().to_string()), + Line::default(), + Line::from(Span::styled( + "Latest activity", + Style::default().fg(Color::DarkGray), + )), + Line::from(if row.thread.preview.is_empty() { + "No activity yet.".to_string() + } else { + row.thread.preview.clone() + }), + ]; + if let Some(branch) = row + .thread + .git_info + .as_ref() + .and_then(|git| git.branch.as_ref()) + { + lines.push(Line::default()); + lines.push(Line::from(Span::styled( + "Branch", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from(branch.clone())); + } + frame.render_widget( + Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: true }), + area, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::agents_overview_view::AgentsOverviewRow; + use app_server_protocol::protocol::v2::{SessionSource, ThreadHistoryMode, ThreadStatus}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + use std::path::PathBuf; + + fn row(id: &str) -> AgentsOverviewRow { + AgentsOverviewRow { + thread: app_server_protocol::protocol::v2::Thread { + id: id.to_string(), + extra: None, + session_id: id.to_string(), + forked_from_id: None, + parent_thread_id: None, + preview: "preview".to_string(), + ephemeral: false, + section: None, + section_entered_at: None, + project_id: None, + history_mode: ThreadHistoryMode::Legacy, + model_provider: "test".to_string(), + created_at: 1, + updated_at: 1, + recency_at: Some(1), + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/workspace"), + cli_version: "test".to_string(), + source: SessionSource::Cli, + can_accept_direct_input: Some(true), + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some(id.to_string()), + turns: Vec::new(), + }, + group: AgentsOverviewGroup::Ready, + is_current: false, + } + } + + #[test] + fn overview_render_is_bounded_on_wide_and_narrow_terminals() { + let view = AgentsOverviewView::new(vec![row("alpha"), row("beta")], None); + for (width, height) in [(120, 30), (24, 10), (16, 8)] { + let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &view, Locale::EnUs)) + .expect("draw"); + } + } + + #[test] + fn overview_render_shows_localized_new_task_input_without_overflow() { + let mut view = AgentsOverviewView::new(vec![row("alpha")], None); + view.handle_event(crossterm::event::Event::Key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('n'), + crossterm::event::KeyModifiers::CONTROL, + ), + )); + for character in "检查任务".chars() { + view.handle_event(crossterm::event::Event::Key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ), + )); + } + let mut terminal = Terminal::new(TestBackend::new(24, 10)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &view, Locale::ZhCn)) + .expect("draw"); + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + let compact = text.chars().filter(|character| !character.is_whitespace()); + let compact = compact.collect::(); + assert!(compact.contains("新建任务"), "{text}"); + assert!(compact.contains("检查任务"), "{text}"); + } +} diff --git a/lime-rs/crates/tui/src/app/agents_overview_tests.rs b/lime-rs/crates/tui/src/app/agents_overview_tests.rs new file mode 100644 index 000000000..0de0e73c1 --- /dev/null +++ b/lime-rs/crates/tui/src/app/agents_overview_tests.rs @@ -0,0 +1,164 @@ +use super::*; +use crate::app::agents_overview_view::AgentsOverviewGroup; +use app_server_protocol::protocol::v2::{ + AgentMessageDeltaNotification, ServerNotification, SessionSource, ThreadHistoryMode, + ThreadNameUpdatedNotification, ThreadStatus, +}; +use std::path::PathBuf; + +fn thread(id: &str, parent: Option<&str>, status: ThreadStatus) -> Thread { + Thread { + id: id.to_string(), + extra: None, + session_id: id.to_string(), + forked_from_id: None, + parent_thread_id: parent.map(str::to_string), + preview: String::new(), + ephemeral: false, + section: None, + section_entered_at: None, + project_id: None, + history_mode: ThreadHistoryMode::Legacy, + model_provider: "test".to_string(), + created_at: 1, + updated_at: 1, + recency_at: Some(1), + status, + path: None, + cwd: PathBuf::from("/workspace"), + cli_version: "test".to_string(), + source: SessionSource::Cli, + can_accept_direct_input: Some(true), + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some(id.to_string()), + turns: Vec::new(), + } +} + +#[test] +fn agents_overview_group_bubbles_child_status_and_hides_ephemeral_threads() { + let mut root = thread("root", None, ThreadStatus::Idle); + let child = thread( + "child", + Some("root"), + ThreadStatus::Active { + active_flags: Vec::new(), + }, + ); + let mut ephemeral = thread("ephemeral", None, ThreadStatus::Idle); + ephemeral.ephemeral = true; + let rows = build_rows(&[root.clone(), child, ephemeral], Some("root")); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].group, AgentsOverviewGroup::Working); + root.status = ThreadStatus::SystemError; + assert_eq!( + build_rows(&[root], None)[0].group, + AgentsOverviewGroup::NeedsYou + ); +} + +#[test] +fn agents_overview_refresh_ignores_stale_generation() { + let mut state = AgentsOverviewState::new(None); + let first = state.begin_refresh(); + state.refreshing = false; + state.request_id = None; + let second = state.begin_refresh(); + assert!(!state.apply_refresh(first, Vec::new(), None)); + assert!(state.apply_refresh(second, Vec::new(), None)); +} + +#[test] +fn agents_overview_refresh_retains_locally_observed_threads() { + let mut state = AgentsOverviewState::new(None); + state.replace_threads(vec![thread("local", None, ThreadStatus::Idle)], None); + state.replace_threads(vec![thread("recent", None, ThreadStatus::Idle)], None); + let ids = state + .threads + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(); + assert!(ids.contains(&"local")); + assert!(ids.contains(&"recent")); +} + +#[test] +fn agents_overview_refresh_coalesces_requests_and_exposes_codex_state_fields() { + let mut state = AgentsOverviewState::new(None); + assert!(!state.initialized); + assert!(state.request_id.is_none()); + let first = state.begin_refresh(); + assert_eq!(state.request_id, Some(first)); + assert!(state.refreshing); + let second = state.begin_refresh(); + assert!(state.refresh_pending); + assert_eq!(first, second); + assert!(state.take_refresh_pending()); + assert!(!state.take_refresh_pending()); +} + +#[test] +fn agents_overview_buffers_latest_notification_during_refresh() { + let mut app = super::super::App::default(); + let mut state = AgentsOverviewState::new(None); + state.replace_threads(vec![thread("background", None, ThreadStatus::Idle)], None); + state.refreshing = true; + app.agents_overview = Some(state); + + let notification = ServerNotification::ThreadNameUpdated(ThreadNameUpdatedNotification { + thread_id: "background".to_string(), + thread_name: Some("renamed".to_string()), + }); + app.track_agents_overview_notification(¬ification); + app.track_agents_overview_notification(&ServerNotification::ThreadNameUpdated( + ThreadNameUpdatedNotification { + thread_id: "background".to_string(), + thread_name: Some("renamed-again".to_string()), + }, + )); + + let overview = app.agents_overview.as_ref().expect("overview state"); + assert_eq!(overview.refresh_notifications["background"].len(), 1); + assert_eq!(overview.visible_thread_ids, vec!["background"]); + assert_eq!( + overview + .view_state + .lock() + .expect("view state") + .visible_rows()[0] + .thread + .name + .as_deref(), + Some("renamed-again") + ); +} + +#[test] +fn background_thread_notifications_do_not_mutate_the_current_projection() { + let mut app = super::super::App::default(); + app.set_thread_id("current".to_string()); + app.agents_overview = Some(AgentsOverviewState::new(Some("current"))); + + app.apply_notification(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "background".to_string(), + turn_id: "turn-background".to_string(), + item_id: "message-background".to_string(), + delta: "background answer".to_string(), + }, + )); + + assert!(app.projection.entries().is_empty()); + app.apply_notification(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "current".to_string(), + turn_id: "turn-current".to_string(), + item_id: "message-current".to_string(), + delta: "current answer".to_string(), + }, + )); + assert_eq!(app.projection.final_answer(), "current answer"); +} diff --git a/lime-rs/crates/tui/src/app/agents_overview_threads.rs b/lime-rs/crates/tui/src/app/agents_overview_threads.rs new file mode 100644 index 000000000..09e474bc9 --- /dev/null +++ b/lime-rs/crates/tui/src/app/agents_overview_threads.rs @@ -0,0 +1,205 @@ +//! App Server thread refresh for Agents Overview. + +use super::App; +use crate::app_server_session::AppServerSession; +use anyhow::{bail, Context, Result}; +use app_server_protocol::protocol::v2::{ + ServerNotification, SortDirection, ThreadListParams, ThreadSortKey, ThreadStatus, +}; + +impl App { + #[allow(dead_code)] + pub(crate) fn track_agents_overview_notification(&mut self, notification: &ServerNotification) { + let primary = self.primary_thread_id.clone(); + let Some(overview) = self.agents_overview.as_mut() else { + return; + }; + let thread_id = match notification { + ServerNotification::ThreadStarted(params) => Some(params.thread.id.as_str()), + ServerNotification::ThreadArchived(params) => Some(params.thread_id.as_str()), + ServerNotification::ThreadDeleted(params) => Some(params.thread_id.as_str()), + ServerNotification::ThreadClosed(params) => Some(params.thread_id.as_str()), + ServerNotification::ThreadStatusChanged(params) => Some(params.thread_id.as_str()), + ServerNotification::ThreadNameUpdated(params) => Some(params.thread_id.as_str()), + _ => None, + }; + match notification { + ServerNotification::ThreadStarted(params) if !params.thread.ephemeral => { + if let Some(existing) = overview + .threads + .iter_mut() + .find(|thread| thread.id == params.thread.id) + { + *existing = params.thread.clone(); + } else { + overview.threads.push(params.thread.clone()); + } + } + ServerNotification::ThreadArchived(params) => { + overview + .threads + .retain(|thread| thread.id != params.thread_id); + } + ServerNotification::ThreadDeleted(params) => { + overview + .threads + .retain(|thread| thread.id != params.thread_id); + } + ServerNotification::ThreadClosed(params) => { + if let Some(thread) = overview + .threads + .iter_mut() + .find(|thread| thread.id == params.thread_id) + { + thread.status = ThreadStatus::NotLoaded; + } + } + ServerNotification::ThreadStatusChanged(params) => { + if let Some(thread) = overview + .threads + .iter_mut() + .find(|thread| thread.id == params.thread_id) + { + thread.status = params.status.clone(); + } + } + ServerNotification::ThreadNameUpdated(params) => { + if let Some(thread) = overview + .threads + .iter_mut() + .find(|thread| thread.id == params.thread_id) + { + thread.name = params.thread_name.clone(); + } + } + _ => return, + } + if let Some(thread_id) = thread_id { + overview.refresh_thread_ids.insert(thread_id.to_string()); + if overview.refreshing { + let pending = overview + .refresh_notifications + .entry(thread_id.to_string()) + .or_default(); + pending.retain(|previous| { + std::mem::discriminant(previous) != std::mem::discriminant(notification) + }); + pending.push(notification.clone()); + } + } + overview + .view + .update_rows(super::agents_overview::build_rows( + &overview.threads, + primary.as_deref(), + )); + overview.visible_thread_ids = overview + .view + .visible_rows() + .into_iter() + .map(|row| row.thread.id.clone()) + .collect(); + overview.sync_view_state(); + } + + #[allow(dead_code)] + pub(crate) async fn refresh_changed_agents_overview_threads( + &mut self, + app_server: &AppServerSession, + ) -> Result<()> { + self.refresh_agents_overview_threads(app_server).await + } + + #[allow(dead_code)] + pub(crate) async fn start_agents_overview_refresh( + &mut self, + app_server: &AppServerSession, + ) -> Result<()> { + self.refresh_agents_overview_threads(app_server).await + } + + pub(crate) async fn refresh_agents_overview_threads( + &mut self, + app_server: &AppServerSession, + ) -> Result<()> { + let Some(overview) = self.agents_overview.as_mut() else { + return Ok(()); + }; + overview + .refresh_thread_ids + .extend(overview.threads.iter().map(|thread| thread.id.clone())); + if overview.refreshing { + overview.refresh_pending = true; + return Ok(()); + } + let generation = overview.begin_refresh(); + let result = async { + let mut cursor = None; + let mut seen = std::collections::HashSet::new(); + let mut threads = Vec::new(); + for _ in 0..16 { + let page = app_server + .thread_list(ThreadListParams { + cursor, + limit: Some(100), + sort_key: Some(ThreadSortKey::RecencyAt), + sort_direction: Some(SortDirection::Desc), + archived: Some(false), + ..ThreadListParams::default() + }) + .await + .context("failed to refresh agents overview threads")?; + threads.extend(page.data); + let Some(next_cursor) = page.next_cursor else { + break; + }; + if !seen.insert(next_cursor.clone()) { + bail!("agents overview thread list repeated cursor {next_cursor}"); + } + cursor = Some(next_cursor); + } + Ok::<_, anyhow::Error>(threads) + } + .await; + match result { + Ok(threads) => { + let primary = self.primary_thread_id.clone(); + if let Some(overview) = self.agents_overview.as_mut() { + overview.apply_refresh(generation, threads, primary.as_deref()); + } + let buffered = self + .agents_overview + .as_mut() + .map(|overview| { + overview + .refresh_notifications + .drain() + .flat_map(|(_, notifications)| notifications) + .collect::>() + }) + .unwrap_or_default(); + for notification in buffered { + self.track_agents_overview_notification(¬ification); + } + let refresh_pending = self + .agents_overview + .as_mut() + .is_some_and(|overview| overview.take_refresh_pending()); + self.projection.set_status("agents overview refreshed"); + if refresh_pending { + Box::pin(self.refresh_agents_overview_threads(app_server)).await?; + } + Ok(()) + } + Err(error) => { + if let Some(overview) = self.agents_overview.as_mut() { + overview.refreshing = false; + overview.request_id = None; + } + self.projection + .set_status(format!("agents overview unavailable: {error}")); + Err(error) + } + } + } +} diff --git a/lime-rs/crates/tui/src/app/agents_overview_view.rs b/lime-rs/crates/tui/src/app/agents_overview_view.rs new file mode 100644 index 000000000..6d828550d --- /dev/null +++ b/lime-rs/crates/tui/src/app/agents_overview_view.rs @@ -0,0 +1,624 @@ +//! Codex-shaped Agents Overview interaction state. +//! +//! The view owns only terminal selection and filtering. Thread metadata and lifecycle remain +//! owned by App Server and are refreshed through `agents_overview_threads`. + +#[path = "agents_overview_render.rs"] +mod render; +pub(crate) use render::render; + +use app_server_protocol::protocol::v2::{Thread, ThreadActiveFlag, ThreadStatus}; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use std::path::PathBuf; + +use crate::key_hint::is_plain_text_key_event; +use crate::keymap::AgentsKeymap; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +pub(crate) enum AgentsOverviewGroup { + NeedsYou, + Working, + Ready, + Finished, +} + +impl AgentsOverviewGroup { + pub(crate) fn for_status(status: &ThreadStatus) -> Self { + match status { + ThreadStatus::Active { active_flags } + if active_flags.contains(&ThreadActiveFlag::WaitingOnApproval) + || active_flags.contains(&ThreadActiveFlag::WaitingOnUserInput) => + { + Self::NeedsYou + } + ThreadStatus::Active { .. } => Self::Working, + ThreadStatus::Idle => Self::Ready, + ThreadStatus::SystemError => Self::NeedsYou, + ThreadStatus::NotLoaded => Self::Finished, + } + } + + #[allow(dead_code)] + pub(crate) fn label(self) -> &'static str { + match self { + Self::NeedsYou => "Needs input", + Self::Working => "Working", + Self::Ready => "Ready", + Self::Finished => "Finished", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct AgentsOverviewRow { + pub(crate) thread: Thread, + pub(crate) group: AgentsOverviewGroup, + pub(crate) is_current: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum AgentsOverviewInputMode { + NewTask, + Rename, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AgentsOverviewAction { + None, + Cancel, + Refresh, + Select, + Dispatch { + prompt: String, + cwd: Option, + }, + Rename { + thread_id: String, + name: String, + }, + Stop { + thread_id: String, + }, + OpenResumePicker, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct AgentsOverviewView { + pub(crate) rows: Vec, + selected: usize, + search: String, + searching: bool, + status_grouping: bool, + input: String, + input_mode: Option, + agents_keymap: AgentsKeymap, +} + +impl AgentsOverviewView { + pub(crate) fn new(rows: Vec, selected_thread_id: Option<&str>) -> Self { + let mut view = Self { + rows, + selected: 0, + search: String::new(), + searching: false, + status_grouping: false, + input: String::new(), + input_mode: None, + agents_keymap: AgentsKeymap, + }; + let visible = view.visible_rows(); + view.selected = selected_thread_id + .and_then(|thread_id| visible.iter().position(|row| row.thread.id == thread_id)) + .or_else(|| visible.iter().position(|row| row.is_current)) + .unwrap_or(0); + view + } + + pub(crate) fn selected_thread_id(&self) -> Option<&str> { + self.visible_rows() + .get(self.selected) + .map(|row| row.thread.id.as_str()) + } + + pub(crate) fn selected_index(&self) -> Option { + (!self.visible_rows().is_empty()).then_some(self.selected) + } + + pub(crate) fn is_searching(&self) -> bool { + self.searching + } + + pub(crate) fn search(&self) -> &str { + &self.search + } + + pub(crate) fn input(&self) -> &str { + &self.input + } + + pub(crate) fn input_mode(&self) -> Option { + self.input_mode + } + + #[allow(dead_code)] + pub(crate) fn status_grouping(&self) -> bool { + self.status_grouping + } + + pub(crate) fn update_rows(&mut self, rows: Vec) { + let selected = self.selected_thread_id().map(str::to_owned); + let search = self.search.clone(); + let searching = self.searching; + let status_grouping = self.status_grouping; + let input = self.input.clone(); + let input_mode = self.input_mode; + *self = Self::new(rows, selected.as_deref()); + self.search = search; + self.searching = searching; + self.status_grouping = status_grouping; + self.input = input; + self.input_mode = input_mode; + self.selected = selected + .as_deref() + .and_then(|thread_id| { + self.visible_rows() + .iter() + .position(|row| row.thread.id == thread_id) + }) + .unwrap_or_else(|| { + self.selected + .min(self.visible_rows().len().saturating_sub(1)) + }); + } + + pub(crate) fn visible_rows(&self) -> Vec<&AgentsOverviewRow> { + let query = self.search.trim().to_ascii_lowercase(); + let mut rows = self + .rows + .iter() + .filter(|row| { + query.is_empty() + || row + .thread + .name + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase() + .contains(&query) + || row.thread.preview.to_ascii_lowercase().contains(&query) + || row + .thread + .cwd + .to_string_lossy() + .to_ascii_lowercase() + .contains(&query) + || row.thread.id.to_ascii_lowercase().contains(&query) + }) + .collect::>(); + if self.status_grouping { + rows.sort_by(|left, right| { + left.group + .cmp(&right.group) + .then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at)) + .then_with(|| left.thread.id.cmp(&right.thread.id)) + }); + } else { + rows.sort_by(|left, right| { + left.thread + .cwd + .cmp(&right.thread.cwd) + .then_with(|| right.thread.updated_at.cmp(&left.thread.updated_at)) + }); + } + rows + } + + pub(crate) fn handle_event(&mut self, event: Event) -> AgentsOverviewAction { + if let Event::Paste(text) = event { + if let Some(mode) = self.input_mode { + match mode { + AgentsOverviewInputMode::NewTask | AgentsOverviewInputMode::Rename => { + self.input.push_str(&text); + } + } + } else if self.searching { + if let Some(query) = crate::clipboard_paste::normalize_pasted_search_query(&text) { + self.search.push_str(&query); + } + self.selected = self + .selected + .min(self.visible_rows().len().saturating_sub(1)); + } + return AgentsOverviewAction::None; + } + let Event::Key(key) = event else { + return AgentsOverviewAction::None; + }; + if key.kind != KeyEventKind::Press { + return AgentsOverviewAction::None; + } + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) + { + return AgentsOverviewAction::Cancel; + } + if let Some(mode) = self.input_mode { + match key.code { + KeyCode::Esc => { + self.input.clear(); + self.input_mode = None; + } + KeyCode::Backspace if key.modifiers.is_empty() => { + self.input.pop(); + } + KeyCode::Enter => { + let input = std::mem::take(&mut self.input); + self.input_mode = None; + if input.trim().is_empty() { + return AgentsOverviewAction::None; + } + match mode { + AgentsOverviewInputMode::NewTask => { + let cwd = (!self.status_grouping) + .then(|| self.selected_row().map(|row| row.thread.cwd.clone())) + .flatten(); + return AgentsOverviewAction::Dispatch { prompt: input, cwd }; + } + AgentsOverviewInputMode::Rename => { + if let Some(thread_id) = + self.selected_row().map(|row| row.thread.id.clone()) + { + return AgentsOverviewAction::Rename { + thread_id, + name: input.trim().to_string(), + }; + } + } + } + } + KeyCode::Char(character) if is_plain_text_key_event(key) => { + self.input.push(character); + } + _ => {} + } + return AgentsOverviewAction::None; + } + if key.code == KeyCode::Esc { + if self.searching { + self.searching = false; + self.search.clear(); + self.selected = 0; + return AgentsOverviewAction::None; + } + return AgentsOverviewAction::Cancel; + } + if (key.code == KeyCode::Char('/') && key.modifiers.is_empty()) + || self.agents_keymap.search(key) + { + self.searching = !self.searching; + if !self.searching { + self.search.clear(); + } + self.selected = 0; + return AgentsOverviewAction::None; + } + if self.searching { + match key.code { + KeyCode::Backspace => { + self.search.pop(); + } + KeyCode::Char(character) if is_plain_text_key_event(key) => { + self.search.push(character); + } + KeyCode::Up | KeyCode::Char('k') => self.move_selection(false), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(true), + KeyCode::Enter => { + self.searching = false; + return AgentsOverviewAction::Select; + } + _ => {} + } + self.selected = self + .selected + .min(self.visible_rows().len().saturating_sub(1)); + return AgentsOverviewAction::None; + } + match key.code { + KeyCode::Up | KeyCode::Char('k') => self.move_selection(false), + KeyCode::Down | KeyCode::Char('j') => self.move_selection(true), + KeyCode::Enter => return AgentsOverviewAction::Select, + KeyCode::Char('o') if self.agents_keymap.resume(key) => { + return AgentsOverviewAction::OpenResumePicker; + } + KeyCode::Char('n') if self.agents_keymap.new_task(key) => { + self.input.clear(); + self.input_mode = Some(AgentsOverviewInputMode::NewTask); + return AgentsOverviewAction::None; + } + KeyCode::Char('r') if self.agents_keymap.rename(key) => { + if let Some(row) = self.selected_row() { + self.input = row.thread.name.clone().unwrap_or_default(); + self.input_mode = Some(AgentsOverviewInputMode::Rename); + } + return AgentsOverviewAction::None; + } + KeyCode::Char('x') if self.agents_keymap.stop(key) => { + if let Some(row) = self.selected_row() { + if matches!(row.thread.status, ThreadStatus::Active { .. }) { + return AgentsOverviewAction::Stop { + thread_id: row.thread.id.clone(), + }; + } + } + return AgentsOverviewAction::None; + } + KeyCode::Char('s') if self.agents_keymap.toggle_grouping(key) => { + self.status_grouping = !self.status_grouping; + self.selected = self + .selected + .min(self.visible_rows().len().saturating_sub(1)); + return AgentsOverviewAction::None; + } + KeyCode::Char('r') if key.modifiers.is_empty() => { + return AgentsOverviewAction::Refresh; + } + KeyCode::Char('g') if key.modifiers.is_empty() => { + self.status_grouping = !self.status_grouping; + self.selected = self + .selected + .min(self.visible_rows().len().saturating_sub(1)); + } + _ => {} + } + AgentsOverviewAction::None + } + + pub(crate) fn selected_row(&self) -> Option<&AgentsOverviewRow> { + self.visible_rows().get(self.selected).copied() + } + + fn move_selection(&mut self, forward: bool) { + let count = self.visible_rows().len(); + if count == 0 { + self.selected = 0; + return; + } + self.selected = if forward { + (self.selected + 1) % count + } else { + self.selected.checked_sub(1).unwrap_or(count - 1) + }; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::ThreadHistoryMode; + use crossterm::event::KeyEvent; + use std::path::PathBuf; + + fn thread(id: &str, status: ThreadStatus, updated_at: i64) -> Thread { + Thread { + id: id.to_string(), + extra: None, + session_id: format!("session-{id}"), + forked_from_id: None, + parent_thread_id: None, + preview: format!("preview-{id}"), + ephemeral: false, + section: None, + section_entered_at: None, + project_id: None, + history_mode: ThreadHistoryMode::Legacy, + model_provider: "test".to_string(), + created_at: updated_at, + updated_at, + recency_at: Some(updated_at), + status, + path: None, + cwd: PathBuf::from("/workspace"), + cli_version: "test".to_string(), + source: app_server_protocol::protocol::v2::SessionSource::Cli, + can_accept_direct_input: Some(true), + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: Some(id.to_string()), + turns: Vec::new(), + } + } + + fn row(id: &str, group: AgentsOverviewGroup, current: bool) -> AgentsOverviewRow { + AgentsOverviewRow { + thread: thread(id, ThreadStatus::Idle, 1), + group, + is_current: current, + } + } + + #[test] + fn overview_groups_and_selects_current_thread() { + let view = AgentsOverviewView::new( + vec![ + row("ready", AgentsOverviewGroup::Ready, false), + row("main", AgentsOverviewGroup::Working, true), + ], + None, + ); + assert_eq!(view.selected_thread_id(), Some("main")); + assert_eq!( + AgentsOverviewGroup::for_status(&ThreadStatus::SystemError), + AgentsOverviewGroup::NeedsYou + ); + } + + #[test] + fn overview_search_filters_and_enter_selects() { + let mut view = AgentsOverviewView::new( + vec![ + row("alpha", AgentsOverviewGroup::Ready, false), + row("beta", AgentsOverviewGroup::Finished, false), + ], + None, + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('/'), + KeyModifiers::NONE + ))), + AgentsOverviewAction::None + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('l'), + KeyModifiers::NONE + ))), + AgentsOverviewAction::None + ); + assert_eq!(view.visible_rows().len(), 1); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE + ))), + AgentsOverviewAction::Select + ); + assert_eq!(view.selected_thread_id(), Some("alpha")); + } + + #[test] + fn overview_escape_and_refresh_are_explicit_actions() { + let mut view = AgentsOverviewView::default(); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::NONE + ))), + AgentsOverviewAction::Refresh + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))), + AgentsOverviewAction::Cancel + ); + } + + #[test] + fn overview_codex_shortcuts_dispatch_rename_stop_and_resume() { + let mut view = AgentsOverviewView::new( + vec![row("ready", AgentsOverviewGroup::Ready, false), { + let mut active = row("active", AgentsOverviewGroup::Working, true); + active.thread.status = ThreadStatus::Active { + active_flags: Vec::new(), + }; + active + }], + Some("active"), + ); + assert_eq!(view.selected_thread_id(), Some("active")); + + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + ))), + AgentsOverviewAction::None + ); + for character in "run checks".chars() { + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char(character), + KeyModifiers::NONE, + ))), + AgentsOverviewAction::None + ); + } + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentsOverviewAction::Dispatch { + prompt: "run checks".to_string(), + cwd: Some(PathBuf::from("/workspace")), + } + ); + + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::CONTROL, + ))), + AgentsOverviewAction::None + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentsOverviewAction::Rename { + thread_id: "active".to_string(), + name: "active".to_string(), + } + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::CONTROL, + ))), + AgentsOverviewAction::Stop { + thread_id: "active".to_string(), + } + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('o'), + KeyModifiers::CONTROL, + ))), + AgentsOverviewAction::OpenResumePicker + ); + } + + #[test] + fn overview_rename_accepts_shifted_characters() { + let mut view = AgentsOverviewView::new( + vec![row("active", AgentsOverviewGroup::Working, true)], + Some("active"), + ); + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('r'), + KeyModifiers::CONTROL, + ))), + AgentsOverviewAction::None + ); + for _ in 0.."active".len() { + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Backspace, + KeyModifiers::NONE, + ))); + } + for (character, modifiers) in [ + ('G', KeyModifiers::SHIFT), + ('a', KeyModifiers::NONE), + ('t', KeyModifiers::NONE), + ('e', KeyModifiers::NONE), + ] { + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char(character), + modifiers, + ))); + } + assert_eq!( + view.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentsOverviewAction::Rename { + thread_id: "active".to_string(), + name: "Gate".to_string(), + } + ); + } +} diff --git a/lime-rs/crates/tui/src/app/app_server_event_targets.rs b/lime-rs/crates/tui/src/app/app_server_event_targets.rs new file mode 100644 index 000000000..37ee08ae1 --- /dev/null +++ b/lime-rs/crates/tui/src/app/app_server_event_targets.rs @@ -0,0 +1,233 @@ +//! Thread targeting helpers for app-server requests and notifications. + +use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest}; + +pub(crate) fn server_request_thread_id(request: &ServerRequest) -> Option<&str> { + match request { + ServerRequest::CurrentTimeRead { params, .. } => Some(params.thread_id.as_str()), + ServerRequest::McpServerElicitationRequest { params, .. } => { + Some(params.thread_id.as_str()) + } + ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => { + Some(params.thread_id.as_str()) + } + ServerRequest::ItemFileChangeRequestApproval { params, .. } => { + Some(params.thread_id.as_str()) + } + ServerRequest::ItemPermissionsRequestApproval { params, .. } => { + Some(params.thread_id.as_str()) + } + ServerRequest::DynamicToolCall { params, .. } => Some(params.thread_id.as_str()), + ServerRequest::ItemToolRequestUserInput { params, .. } => Some(params.thread_id.as_str()), + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ServerNotificationThreadTarget { + Thread(String), + InvalidThreadId(String), + AppScoped, + Global, +} + +pub(super) fn server_notification_thread_target( + notification: &ServerNotification, +) -> ServerNotificationThreadTarget { + let thread_id = match notification { + ServerNotification::Warning(notification) => notification.thread_id.as_deref(), + ServerNotification::GuardianWarning(notification) => Some(notification.thread_id.as_str()), + ServerNotification::StrictReviewRequired(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::Error(notification) => Some(notification.thread_id.as_str()), + ServerNotification::McpServerOauthLoginCompleted(notification) => { + notification.thread_id.as_deref() + } + ServerNotification::McpServerStatusUpdated(notification) => { + let Some(thread_id) = notification.thread_id.as_deref() else { + return ServerNotificationThreadTarget::AppScoped; + }; + Some(thread_id) + } + ServerNotification::EnvironmentConnected(notification) + | ServerNotification::EnvironmentDisconnected(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ScheduledTaskRunUpdated(notification) => { + notification.thread_id.as_deref() + } + ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::HookCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadStarted(notification) => Some(notification.thread.id.as_str()), + ServerNotification::ThreadArchived(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadDeleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadUnarchived(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadClosed(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadReverted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ThreadNameUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadStatusChanged(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnDiffUpdated(notification) => Some(notification.thread_id.as_str()), + ServerNotification::TurnPlanUpdated(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ItemStarted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ItemAutoApprovalReviewStarted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ItemAutoApprovalReviewCompleted(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::AgentMessageDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::CommandExecutionOutputDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::CommandExecutionTerminalInteraction(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::FileChangePatchUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::PlanDelta(notification) => Some(notification.thread_id.as_str()), + ServerNotification::McpToolCallProgress(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningSummaryTextDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningSummaryPartAdded(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ReasoningTextDelta(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ModelVerification(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::TurnModerationMetadata(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ModelSafetyBufferingUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadSettingsUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadTokenUsageUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadGoalUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadGoalCleared(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadQueueChanged(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadProjectUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ServerRequestResolved(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ConfigWarning(_) + | ServerNotification::SkillsChanged(_) + | ServerNotification::McpServerEventStream(_) + | ServerNotification::AppListUpdated(_) + | ServerNotification::ScheduledTaskChanged(_) + | ServerNotification::ModelListUpdated(_) + | ServerNotification::FsChanged(_) + | ServerNotification::ProcessOutputDelta(_) + | ServerNotification::ProcessExited(_) + | ServerNotification::CommandExecOutputDelta(_) + | ServerNotification::ProjectChanged(_) + | ServerNotification::WindowsWorldWritableWarning(_) + | ServerNotification::WindowsSandboxSetupCompleted(_) => None, + }; + + match thread_id { + Some(thread_id) if thread_id.trim().is_empty() => { + ServerNotificationThreadTarget::InvalidThreadId(thread_id.to_string()) + } + Some(thread_id) => ServerNotificationThreadTarget::Thread(thread_id.to_string()), + None => ServerNotificationThreadTarget::Global, + } +} + +#[cfg(test)] +mod tests { + use super::{ + server_notification_thread_target, server_request_thread_id, ServerNotificationThreadTarget, + }; + use app_server_protocol::protocol::v2::{ + AgentMessageDeltaNotification, CurrentTimeReadParams, McpServerStartupState, + McpServerStatusUpdatedNotification, ServerNotification, ServerRequest, WarningNotification, + }; + use app_server_protocol::RequestId; + + #[test] + fn warning_notifications_without_threads_are_global() { + let notification = ServerNotification::Warning(WarningNotification { + thread_id: None, + message: "warning".to_string(), + code: None, + }); + + assert_eq!( + server_notification_thread_target(¬ification), + ServerNotificationThreadTarget::Global + ); + } + + #[test] + fn thread_notifications_route_to_their_thread() { + let notification = ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "answer".to_string(), + }); + + assert_eq!( + server_notification_thread_target(¬ification), + ServerNotificationThreadTarget::Thread("thread-1".to_string()) + ); + } + + #[test] + fn mcp_startup_notifications_without_threads_are_app_scoped() { + let notification = + ServerNotification::McpServerStatusUpdated(McpServerStatusUpdatedNotification { + thread_id: None, + name: "server".to_string(), + status: McpServerStartupState::Failed, + error: Some("not available".to_string()), + failure_reason: None, + }); + + assert_eq!( + server_notification_thread_target(¬ification), + ServerNotificationThreadTarget::AppScoped + ); + } + + #[test] + fn server_requests_expose_their_thread_target() { + let request = ServerRequest::CurrentTimeRead { + id: RequestId::Integer(1), + params: CurrentTimeReadParams { + thread_id: "thread-1".to_string(), + }, + }; + + assert_eq!(server_request_thread_id(&request), Some("thread-1")); + } +} diff --git a/lime-rs/crates/tui/src/app/app_server_events.rs b/lime-rs/crates/tui/src/app/app_server_events.rs new file mode 100644 index 000000000..b2d41e592 --- /dev/null +++ b/lime-rs/crates/tui/src/app/app_server_events.rs @@ -0,0 +1,63 @@ +//! App-server event stream handling for the TUI app. + +use super::App; +use crate::app_server_session::AppServerSession; +use app_server_client::AppServerEvent; +use app_server_protocol::protocol::v2::ServerNotification; + +impl App { + pub(crate) async fn handle_app_server_event( + &mut self, + app_server_client: &AppServerSession, + event: AppServerEvent, + ) { + match event { + AppServerEvent::Lagged { .. } => {} + AppServerEvent::ServerNotification(notification) => { + self.handle_server_notification_event(app_server_client, *notification) + .await; + } + AppServerEvent::ServerRequest(request) => { + self.handle_server_request_event(app_server_client, *request) + .await; + } + AppServerEvent::Disconnected { message } => { + self.bottom_pane.clear(); + self.model_picker = None; + self.pager_overlay = None; + self.projection + .set_status(format!("reconnecting: {message}")); + } + } + } + + async fn handle_server_notification_event( + &mut self, + app_server_client: &AppServerSession, + notification: ServerNotification, + ) { + let queue_changed = matches!( + ¬ification, + ServerNotification::ThreadQueueChanged(params) + if self.thread_id.as_deref() == Some(params.thread_id.as_str()) + ); + self.apply_notification(notification); + if queue_changed { + self.refresh_queued_submissions(app_server_client).await; + } + } + + pub(crate) async fn refresh_queued_submissions( + &mut self, + app_server_client: &AppServerSession, + ) { + match app_server_client.list_queued_submissions(100).await { + Ok(submissions) => self.set_queued_submissions(submissions), + Err(error) => { + self.set_queued_submissions(Vec::new()); + self.projection + .set_status(format!("queue unavailable: {error}")); + } + } + } +} diff --git a/lime-rs/crates/tui/src/app/app_server_requests.rs b/lime-rs/crates/tui/src/app/app_server_requests.rs new file mode 100644 index 000000000..80f3770a3 --- /dev/null +++ b/lime-rs/crates/tui/src/app/app_server_requests.rs @@ -0,0 +1,68 @@ +//! App Server reverse-request routing for the TUI. +//! +//! Requests that need a user decision enter the BottomPane queue. Requests for a foreign thread +//! are retained only when the Agents Overview has an explicit interaction surface; unsupported +//! requests fail closed at the App Server boundary. + +use super::app_server_event_targets::server_request_thread_id; +use super::App; +use crate::app_server_session::AppServerSession; +use app_server_protocol::protocol::v2::ServerRequest; + +impl App { + pub(crate) async fn handle_server_request_event( + &mut self, + app_server_client: &AppServerSession, + request: ServerRequest, + ) { + match request { + ServerRequest::CurrentTimeRead { id, .. } => { + if let Err(error) = app_server_client.respond_current_time(id).await { + self.projection.set_status(error.to_string()); + } + } + request => { + // Requests without a TUI interaction surface must fail closed instead of being + // retained for an impossible replay. + let unsupported_request = matches!( + &request, + ServerRequest::McpServerElicitationRequest { .. } + | ServerRequest::DynamicToolCall { .. } + ); + let thread_id = server_request_thread_id(&request).map(str::to_owned); + if let Some(thread_id) = thread_id { + if self.thread_id.as_deref() != Some(thread_id.as_str()) { + if !unsupported_request { + match self.enqueue_thread_request(&thread_id, request) { + Ok(()) => return, + Err(request) => { + if let Err(error) = + app_server_client.reject_server_request(request).await + { + self.projection.set_status(error.to_string()); + } + return; + } + } + } + if let Err(error) = app_server_client + .reject_server_request(Box::new(request)) + .await + { + self.projection.set_status(error.to_string()); + } + return; + } + } + match self.bottom_pane.enqueue(request) { + Ok(()) => self.pager_overlay = None, + Err(request) => { + if let Err(error) = app_server_client.reject_server_request(request).await { + self.projection.set_status(error.to_string()); + } + } + } + } + } + } +} diff --git a/lime-rs/crates/tui/src/app/event_dispatch.rs b/lime-rs/crates/tui/src/app/event_dispatch.rs new file mode 100644 index 000000000..f9d987515 --- /dev/null +++ b/lime-rs/crates/tui/src/app/event_dispatch.rs @@ -0,0 +1,193 @@ +//! Application action dispatch for the TUI. +//! +//! This is the Codex-shaped owner for actions that require App Server state. Input decoding stays +//! in `input.rs`; terminal-specific actions remain in the runtime because they need the live +//! terminal guard. The split keeps the central loop focused on event ordering and lifecycle. + +use anyhow::Result; + +use super::*; +use crate::app_server_session::AppServerSession; +use crate::settings::{cycle_setting, EFFORTS}; + +pub(crate) struct EventContext<'a> { + pub(crate) session: &'a mut AppServerSession, + pub(crate) model: &'a mut Option, + pub(crate) model_provider: &'a mut Option, + pub(crate) effort: &'a mut Option, + pub(crate) permissions: &'a mut Option, +} + +pub(crate) enum EventDispatch { + Handled, + Unhandled(AppAction), +} + +impl App { + /// Dispatch one App action that belongs to the App Server-backed application layer. + pub(crate) async fn handle_event( + &mut self, + event: AppAction, + context: EventContext<'_>, + ) -> Result { + match event { + action @ (AppAction::DecreaseEffort | AppAction::IncreaseEffort) => { + let direction = if matches!(action, AppAction::DecreaseEffort) { + -1 + } else { + 1 + }; + let next = cycle_setting(&EFFORTS, context.effort.as_deref(), direction); + match context + .session + .update_settings(None, None, Some(next.clone()), None) + .await + { + Ok(()) => { + *context.effort = Some(next.clone()); + self.set_settings( + context.model.clone(), + context.model_provider.clone(), + context.effort.clone(), + context.permissions.clone(), + ); + self.projection.set_status(format!("effort: {next}")); + } + Err(error) => self.projection.set_status(error.to_string()), + } + Ok(EventDispatch::Handled) + } + action @ (AppAction::PreviousPermissions | AppAction::NextPermissions) => { + let direction = if matches!(action, AppAction::PreviousPermissions) { + -1 + } else { + 1 + }; + let next = self.cycle_permission_profile(context.permissions.as_deref(), direction); + match context + .session + .update_settings(None, None, None, Some(next.clone())) + .await + { + Ok(()) => { + *context.permissions = Some(next.clone()); + self.set_settings( + context.model.clone(), + context.model_provider.clone(), + context.effort.clone(), + context.permissions.clone(), + ); + self.projection.set_status(format!("permissions: {next}")); + } + Err(error) => self.projection.set_status(error.to_string()), + } + Ok(EventDispatch::Handled) + } + AppAction::Respond(response) => { + self.note_outbound_response(&response); + if let Err(error) = context.session.respond(response).await { + self.projection.set_status(error.to_string()); + } + Ok(EventDispatch::Handled) + } + AppAction::SelectModel(selection) => { + match context + .session + .update_settings( + Some(selection.model.clone()), + Some(selection.provider.clone()), + None, + None, + ) + .await + { + Ok(()) => { + *context.model = Some(selection.model); + *context.model_provider = Some(selection.provider); + self.set_settings( + context.model.clone(), + context.model_provider.clone(), + context.effort.clone(), + context.permissions.clone(), + ); + self.projection.set_status("settings updated"); + } + Err(error) => self.projection.set_status(error.to_string()), + } + Ok(EventDispatch::Handled) + } + AppAction::ChangeCollaborationMode(collaboration_mode) => { + match context + .session + .update_collaboration_mode(collaboration_mode.clone()) + .await + { + Ok(()) => { + *context.model = Some(collaboration_mode.settings.model.clone()); + *context.effort = collaboration_mode.settings.reasoning_effort.clone(); + self.set_settings( + context.model.clone(), + context.model_provider.clone(), + context.effort.clone(), + context.permissions.clone(), + ); + self.collaboration_mode = Some(collaboration_mode); + self.projection.set_status("collaboration mode updated"); + } + Err(error) => self.projection.set_status(error.to_string()), + } + Ok(EventDispatch::Handled) + } + AppAction::RefreshAgentsOverview => { + if let Err(error) = self.refresh_agents_overview_threads(context.session).await { + self.projection.set_status(error.to_string()); + } + Ok(EventDispatch::Handled) + } + AppAction::DispatchAgentsOverviewTask { prompt, cwd } => { + match self + .dispatch_agents_overview_task(context.session, prompt, cwd) + .await + { + Ok((thread_id, turn_id)) => self + .projection + .set_status(format!("background task started: {thread_id} ({turn_id})")), + Err(error) => self + .projection + .set_status(format!("background task failed: {error}")), + } + Ok(EventDispatch::Handled) + } + AppAction::RenameAgentsOverviewThread { thread_id, name } => { + match context.session.thread_set_name(thread_id, name).await { + Ok(()) => { + self.projection.set_status("agent renamed"); + self.refresh_agents_overview_threads(context.session) + .await + .ok(); + } + Err(error) => self + .projection + .set_status(format!("agent rename failed: {error}")), + } + Ok(EventDispatch::Handled) + } + AppAction::StopAgentsOverviewThread { thread_id } => { + match self + .stop_agents_overview_thread(context.session, thread_id) + .await + { + Ok(Some(turn_id)) => self + .projection + .set_status(format!("stopping background turn {turn_id}")), + Ok(None) => self.projection.set_status("background task is idle"), + Err(error) => self + .projection + .set_status(format!("background task stop failed: {error}")), + } + Ok(EventDispatch::Handled) + } + other => Ok(EventDispatch::Unhandled(other)), + } + } +} diff --git a/lime-rs/crates/tui/src/app/input.rs b/lime-rs/crates/tui/src/app/input.rs new file mode 100644 index 000000000..4395d0887 --- /dev/null +++ b/lime-rs/crates/tui/src/app/input.rs @@ -0,0 +1,144 @@ +//! Keyboard input dispatch for the TUI app. + +use super::*; +use crate::app::agent_navigation::AgentNavigationDirection; +use crate::pending_input_preview::can_restore_submission; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +impl App { + pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> AppAction { + if self.resume_picker.is_none() + && self.agents_overview.is_none() + && key_event.kind == KeyEventKind::Press + { + match key_event.code { + KeyCode::PageUp => return AppAction::ScrollUp, + KeyCode::PageDown => return AppAction::ScrollDown, + KeyCode::Home if key_event.modifiers.contains(KeyModifiers::ALT) => { + return AppAction::ScrollTop; + } + KeyCode::End if key_event.modifiers.contains(KeyModifiers::ALT) => { + return AppAction::ScrollBottom; + } + _ => {} + } + } + + if key_event.kind == KeyEventKind::Press + && self.projection.active_turn_id().is_none() + && self.composer.is_empty() + { + let direction = if crate::multi_agents::previous_agent_shortcut_matches(key_event, true) + { + Some(AgentNavigationDirection::Previous) + } else if crate::multi_agents::next_agent_shortcut_matches(key_event, true) { + Some(AgentNavigationDirection::Next) + } else { + None + }; + if let Some(direction) = direction { + return self + .adjacent_agent(direction) + .map(AppAction::SwitchThread) + .unwrap_or(AppAction::None); + } + } + if key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::BackTab + && self.projection.active_turn_id().is_none() + { + return self + .next_collaboration_mode() + .map(AppAction::ChangeCollaborationMode) + .unwrap_or(AppAction::None); + } + + match key_event { + KeyEvent { + code: KeyCode::Esc, + kind: KeyEventKind::Press, + .. + } if self.projection.active_turn_id().is_some() => AppAction::Interrupt, + KeyEvent { + code: KeyCode::Char(value), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && value.eq_ignore_ascii_case(&'v') => + { + AppAction::PasteImage + } + KeyEvent { + code: KeyCode::Char(value), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && value.eq_ignore_ascii_case(&'o') => { + AppAction::CopyLastResponse + } + KeyEvent { + code: KeyCode::Char(value), + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::CONTROL) && value.eq_ignore_ascii_case(&'t') => { + self.command_popup = None; + self.pager_overlay = Some(PagerOverlay::transcript(self.locale)); + AppAction::None + } + KeyEvent { + code: KeyCode::Up, + modifiers, + kind: KeyEventKind::Press, + .. + } if modifiers.contains(KeyModifiers::ALT) + && self.composer.is_empty() + && !self.composer.has_pending_images() => + { + self.queued_submissions + .last() + .filter(|submission| can_restore_submission(submission)) + .cloned() + .map(AppAction::EditQueuedSubmission) + .unwrap_or(AppAction::None) + } + KeyEvent { + kind: KeyEventKind::Press, + .. + } => { + if key_event.code == KeyCode::Enter + && !key_event + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT) + { + if let Some(action) = self.run_local_command() { + return action; + } + } + if matches!(key_event.code, KeyCode::Enter | KeyCode::Tab) + && self.composer.is_empty() + && self.composer.has_pending_images() + { + return if key_event.code == KeyCode::Tab { + AppAction::Queue(String::new()) + } else { + AppAction::Submit(String::new()) + }; + } + if key_event.code == KeyCode::Backspace + && self.composer.is_empty() + && self.composer.remove_last_pending_image() + { + return AppAction::None; + } + let action = self.composer.handle_key_event(key_event); + self.map_composer_action(action) + } + _ => { + let action = self.composer.handle_key_event(key_event); + self.map_composer_action(action) + } + } + } +} diff --git a/lime-rs/crates/tui/src/app/pending_interactive_replay.rs b/lime-rs/crates/tui/src/app/pending_interactive_replay.rs new file mode 100644 index 000000000..0cc555fc3 --- /dev/null +++ b/lime-rs/crates/tui/src/app/pending_interactive_replay.rs @@ -0,0 +1,750 @@ +//! Pending interactive request state used by Codex-shaped thread event replay. +//! +//! A request can outlive the view that first received it. The replay state is therefore kept +//! separately from the bounded event queue and is updated by inbound server lifecycle +//! notifications, outbound responses, and queue eviction. + +use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem}; +use app_server_protocol::RequestId; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct ElicitationRequestKey { + server_name: String, + request_id: RequestId, +} + +impl ElicitationRequestKey { + fn new(server_name: String, request_id: RequestId) -> Self { + Self { + server_name, + request_id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PendingInteractiveRequest { + ExecApproval { + turn_id: String, + item_id: String, + approval_id: String, + }, + PatchApproval { + turn_id: String, + item_id: String, + }, + Elicitation(ElicitationRequestKey), + RequestPermissions { + turn_id: String, + item_id: String, + }, + RequestUserInput { + turn_id: String, + item_id: String, + }, +} + +#[derive(Debug, Default)] +// Tracks which interactive prompts are still unresolved in the thread-event buffer. +// +// Thread snapshots are replayed when switching threads/agents. Most events replay verbatim, but +// approvals and input prompts only replay while they are still pending. The fast lookup sets are +// paired with turn-indexed queues so turn completion and FIFO user-input answers can clear stale +// prompts without scanning the buffered event list. +pub(super) struct PendingInteractiveReplayState { + exec_approval_call_ids: HashSet, + exec_approval_call_ids_by_turn_id: HashMap>, + patch_approval_call_ids: HashSet, + patch_approval_call_ids_by_turn_id: HashMap>, + elicitation_requests: HashSet, + request_permissions_call_ids: HashSet, + request_permissions_call_ids_by_turn_id: HashMap>, + request_user_input_call_ids: HashSet, + request_user_input_call_ids_by_turn_id: HashMap>, + pending_requests_by_request_id: HashMap, +} + +impl PendingInteractiveReplayState { + pub(super) fn note_server_request(&mut self, request: &ServerRequest) { + // Request IDs are the response identity in Lime's protocol. If a server reuses one, make + // sure the old classification cannot leak into the replacement request. + self.remove_request(request.id()); + + match request { + ServerRequest::ItemCommandExecutionRequestApproval { id, params } => { + let approval_id = params + .approval_id + .clone() + .unwrap_or_else(|| params.item_id.clone()); + self.exec_approval_call_ids.insert(approval_id.clone()); + self.exec_approval_call_ids_by_turn_id + .entry(params.turn_id.clone()) + .or_default() + .push(approval_id.clone()); + self.pending_requests_by_request_id.insert( + id.clone(), + PendingInteractiveRequest::ExecApproval { + turn_id: params.turn_id.clone(), + item_id: params.item_id.clone(), + approval_id, + }, + ); + } + ServerRequest::ItemFileChangeRequestApproval { id, params } => { + self.patch_approval_call_ids.insert(params.item_id.clone()); + self.patch_approval_call_ids_by_turn_id + .entry(params.turn_id.clone()) + .or_default() + .push(params.item_id.clone()); + self.pending_requests_by_request_id.insert( + id.clone(), + PendingInteractiveRequest::PatchApproval { + turn_id: params.turn_id.clone(), + item_id: params.item_id.clone(), + }, + ); + } + ServerRequest::McpServerElicitationRequest { id, params } => { + let key = ElicitationRequestKey::new(params.server_name.clone(), id.clone()); + self.elicitation_requests.insert(key.clone()); + self.pending_requests_by_request_id + .insert(id.clone(), PendingInteractiveRequest::Elicitation(key)); + } + ServerRequest::ItemPermissionsRequestApproval { id, params } => { + self.request_permissions_call_ids + .insert(params.item_id.clone()); + self.request_permissions_call_ids_by_turn_id + .entry(params.turn_id.clone()) + .or_default() + .push(params.item_id.clone()); + self.pending_requests_by_request_id.insert( + id.clone(), + PendingInteractiveRequest::RequestPermissions { + turn_id: params.turn_id.clone(), + item_id: params.item_id.clone(), + }, + ); + } + ServerRequest::ItemToolRequestUserInput { id, params } => { + self.request_user_input_call_ids + .insert(params.item_id.clone()); + self.request_user_input_call_ids_by_turn_id + .entry(params.turn_id.clone()) + .or_default() + .push(params.item_id.clone()); + self.pending_requests_by_request_id.insert( + id.clone(), + PendingInteractiveRequest::RequestUserInput { + turn_id: params.turn_id.clone(), + item_id: params.item_id.clone(), + }, + ); + } + ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => {} + } + } + + pub(super) fn note_server_notification(&mut self, notification: &ServerNotification) { + match notification { + ServerNotification::ItemStarted(notification) => match ¬ification.item { + ThreadItem::CommandExecution { id, .. } => { + self.clear_exec_approval_item(id); + } + ThreadItem::FileChange { id, .. } => { + self.clear_patch_approval_item(id); + } + _ => {} + }, + ServerNotification::TurnCompleted(params) => { + self.clear_exec_approval_turn(¶ms.turn.id); + self.clear_patch_approval_turn(¶ms.turn.id); + self.clear_request_permissions_turn(¶ms.turn.id); + self.clear_request_user_input_turn(¶ms.turn.id); + } + ServerNotification::ServerRequestResolved(params) => { + self.remove_request(¶ms.request_id); + } + ServerNotification::ThreadClosed(_) => self.clear(), + _ => {} + } + } + + pub(super) fn note_evicted_server_request(&mut self, request: &ServerRequest) { + if self + .pending_requests_by_request_id + .contains_key(request.id()) + { + self.remove_request(request.id()); + return; + } + + // The request may have been replaced or filtered before eviction. Remove its category + // identity as a defensive fallback so an evicted prompt can never replay. + match request { + ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => { + let approval_id = params.approval_id.as_ref().unwrap_or(¶ms.item_id); + self.exec_approval_call_ids.remove(approval_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.exec_approval_call_ids_by_turn_id, + ¶ms.turn_id, + approval_id, + ); + } + ServerRequest::ItemFileChangeRequestApproval { params, .. } => { + self.patch_approval_call_ids.remove(¶ms.item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.patch_approval_call_ids_by_turn_id, + ¶ms.turn_id, + ¶ms.item_id, + ); + } + ServerRequest::McpServerElicitationRequest { id, params } => { + self.elicitation_requests + .remove(&ElicitationRequestKey::new( + params.server_name.clone(), + id.clone(), + )); + } + ServerRequest::ItemPermissionsRequestApproval { params, .. } => { + self.request_permissions_call_ids.remove(¶ms.item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_permissions_call_ids_by_turn_id, + ¶ms.turn_id, + ¶ms.item_id, + ); + } + ServerRequest::ItemToolRequestUserInput { params, .. } => { + self.request_user_input_call_ids.remove(¶ms.item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_user_input_call_ids_by_turn_id, + ¶ms.turn_id, + ¶ms.item_id, + ); + } + ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => {} + } + } + + pub(super) fn note_outbound_response(&mut self, request_id: &RequestId) { + self.remove_request(request_id); + } + + pub(super) fn should_replay_snapshot_request(&self, request: &ServerRequest) -> bool { + match request { + ServerRequest::ItemCommandExecutionRequestApproval { params, .. } => self + .exec_approval_call_ids + .contains(params.approval_id.as_ref().unwrap_or(¶ms.item_id)), + ServerRequest::ItemFileChangeRequestApproval { params, .. } => { + self.patch_approval_call_ids.contains(¶ms.item_id) + } + ServerRequest::McpServerElicitationRequest { id, params } => self + .elicitation_requests + .contains(&ElicitationRequestKey::new( + params.server_name.clone(), + id.clone(), + )), + ServerRequest::ItemPermissionsRequestApproval { params, .. } => { + self.request_permissions_call_ids.contains(¶ms.item_id) + } + ServerRequest::ItemToolRequestUserInput { params, .. } => { + self.request_user_input_call_ids.contains(¶ms.item_id) + } + ServerRequest::CurrentTimeRead { .. } | ServerRequest::DynamicToolCall { .. } => true, + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn has_pending_thread_approvals(&self) -> bool { + !self.exec_approval_call_ids.is_empty() + || !self.patch_approval_call_ids.is_empty() + || !self.elicitation_requests.is_empty() + || !self.request_permissions_call_ids.is_empty() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn has_pending_thread_user_input(&self) -> bool { + !self.request_user_input_call_ids.is_empty() + } + + fn clear_exec_approval_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self.exec_approval_call_ids_by_turn_id.remove(turn_id) { + for call_id in call_ids { + self.exec_approval_call_ids.remove(&call_id); + } + } + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::ExecApproval { + turn_id: pending_turn_id, + .. + } if pending_turn_id == turn_id + ) + }); + } + + fn clear_exec_approval_item(&mut self, item_id: &str) { + let matching = self + .pending_requests_by_request_id + .values() + .filter_map(|pending| match pending { + PendingInteractiveRequest::ExecApproval { + turn_id, + item_id: pending_item_id, + approval_id, + } if pending_item_id == item_id => Some((turn_id.clone(), approval_id.clone())), + _ => None, + }) + .collect::>(); + self.exec_approval_call_ids.remove(item_id); + for (turn_id, approval_id) in matching { + self.exec_approval_call_ids.remove(&approval_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.exec_approval_call_ids_by_turn_id, + &turn_id, + &approval_id, + ); + } + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::ExecApproval { item_id: pending_item_id, .. } + if pending_item_id == item_id + ) + }); + } + + fn clear_patch_approval_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self.patch_approval_call_ids_by_turn_id.remove(turn_id) { + for call_id in call_ids { + self.patch_approval_call_ids.remove(&call_id); + } + } + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::PatchApproval { + turn_id: pending_turn_id, + .. + } if pending_turn_id == turn_id + ) + }); + } + + fn clear_patch_approval_item(&mut self, item_id: &str) { + self.patch_approval_call_ids.remove(item_id); + Self::remove_call_id_from_turn_map(&mut self.patch_approval_call_ids_by_turn_id, item_id); + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::PatchApproval { item_id: pending_item_id, .. } + if pending_item_id == item_id + ) + }); + } + + fn clear_request_permissions_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self.request_permissions_call_ids_by_turn_id.remove(turn_id) { + for call_id in call_ids { + self.request_permissions_call_ids.remove(&call_id); + } + } + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::RequestPermissions { + turn_id: pending_turn_id, + .. + } if pending_turn_id == turn_id + ) + }); + } + + fn clear_request_user_input_turn(&mut self, turn_id: &str) { + if let Some(call_ids) = self.request_user_input_call_ids_by_turn_id.remove(turn_id) { + for call_id in call_ids { + self.request_user_input_call_ids.remove(&call_id); + } + } + self.pending_requests_by_request_id.retain(|_, pending| { + !matches!( + pending, + PendingInteractiveRequest::RequestUserInput { + turn_id: pending_turn_id, + .. + } if pending_turn_id == turn_id + ) + }); + } + + fn remove_call_id_from_turn_map( + call_ids_by_turn_id: &mut HashMap>, + call_id: &str, + ) { + call_ids_by_turn_id.retain(|_, call_ids| { + call_ids.retain(|queued_call_id| queued_call_id != call_id); + !call_ids.is_empty() + }); + } + + fn remove_call_id_from_turn_map_entry( + call_ids_by_turn_id: &mut HashMap>, + turn_id: &str, + call_id: &str, + ) { + let mut remove_turn_entry = false; + if let Some(call_ids) = call_ids_by_turn_id.get_mut(turn_id) { + call_ids.retain(|queued_call_id| queued_call_id != call_id); + remove_turn_entry = call_ids.is_empty(); + } + if remove_turn_entry { + call_ids_by_turn_id.remove(turn_id); + } + } + + fn clear(&mut self) { + self.exec_approval_call_ids.clear(); + self.exec_approval_call_ids_by_turn_id.clear(); + self.patch_approval_call_ids.clear(); + self.patch_approval_call_ids_by_turn_id.clear(); + self.elicitation_requests.clear(); + self.request_permissions_call_ids.clear(); + self.request_permissions_call_ids_by_turn_id.clear(); + self.request_user_input_call_ids.clear(); + self.request_user_input_call_ids_by_turn_id.clear(); + self.pending_requests_by_request_id.clear(); + } + + fn remove_request(&mut self, request_id: &RequestId) { + let Some(pending) = self.pending_requests_by_request_id.remove(request_id) else { + return; + }; + match pending { + PendingInteractiveRequest::ExecApproval { + turn_id, + approval_id, + .. + } => { + self.exec_approval_call_ids.remove(&approval_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.exec_approval_call_ids_by_turn_id, + &turn_id, + &approval_id, + ); + } + PendingInteractiveRequest::PatchApproval { turn_id, item_id } => { + self.patch_approval_call_ids.remove(&item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.patch_approval_call_ids_by_turn_id, + &turn_id, + &item_id, + ); + } + PendingInteractiveRequest::Elicitation(key) => { + self.elicitation_requests.remove(&key); + } + PendingInteractiveRequest::RequestPermissions { turn_id, item_id } => { + self.request_permissions_call_ids.remove(&item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_permissions_call_ids_by_turn_id, + &turn_id, + &item_id, + ); + } + PendingInteractiveRequest::RequestUserInput { turn_id, item_id } => { + self.request_user_input_call_ids.remove(&item_id); + Self::remove_call_id_from_turn_map_entry( + &mut self.request_user_input_call_ids_by_turn_id, + &turn_id, + &item_id, + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::{ + CommandExecutionRequestApprovalParams, FileChangeRequestApprovalParams, + ItemStartedNotification, McpServerElicitationRequest, McpServerElicitationRequestParams, + PermissionsRequestApprovalParams, RequestPermissionProfile, + ServerRequestResolvedNotification, ThreadClosedNotification, ToolRequestUserInputParams, + Turn, TurnCompletedNotification, TurnItemsView, TurnStatus, + }; + + fn request_user_input(id: i64, item_id: &str, turn_id: &str) -> ServerRequest { + ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(id), + params: ToolRequestUserInputParams { + thread_id: "thread".to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }, + } + } + + fn exec_approval( + id: i64, + item_id: &str, + approval_id: Option<&str>, + turn_id: &str, + ) -> ServerRequest { + ServerRequest::ItemCommandExecutionRequestApproval { + id: RequestId::Integer(id), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread".to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + started_at_ms: 0, + approval_id: approval_id.map(str::to_string), + reason: None, + network_approval_context: None, + command: Some("echo ok".to_string()), + cwd: None, + available_decisions: None, + }, + } + } + + fn patch_approval(id: i64, item_id: &str, turn_id: &str) -> ServerRequest { + ServerRequest::ItemFileChangeRequestApproval { + id: RequestId::Integer(id), + params: FileChangeRequestApprovalParams { + thread_id: "thread".to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + started_at_ms: 0, + reason: None, + grant_root: None, + }, + } + } + + fn permissions_approval(id: i64, item_id: &str, turn_id: &str) -> ServerRequest { + ServerRequest::ItemPermissionsRequestApproval { + id: RequestId::Integer(id), + params: PermissionsRequestApprovalParams { + thread_id: "thread".to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + environment_id: None, + started_at_ms: 0, + cwd: "/tmp".to_string(), + reason: None, + permissions: RequestPermissionProfile { + network: None, + file_system: None, + }, + }, + } + } + + fn elicitation(id: &str) -> ServerRequest { + ServerRequest::McpServerElicitationRequest { + id: RequestId::String(id.to_string()), + params: McpServerElicitationRequestParams { + thread_id: "thread".to_string(), + turn_id: Some("turn".to_string()), + server_name: "server".to_string(), + request: McpServerElicitationRequest::Form { + meta: None, + message: "Confirm".to_string(), + requested_schema: serde_json::Map::new(), + }, + }, + } + } + + fn turn_completed(turn_id: &str) -> ServerNotification { + ServerNotification::TurnCompleted(TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: Turn { + id: turn_id.to_string(), + items: Vec::new(), + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }) + } + + fn resolved(id: RequestId) -> ServerNotification { + ServerNotification::ServerRequestResolved(ServerRequestResolvedNotification { + thread_id: "thread".to_string(), + request_id: id, + }) + } + + #[test] + fn each_interactive_request_is_pending_until_resolved() { + let mut state = PendingInteractiveReplayState::default(); + let requests = [ + request_user_input(1, "input", "turn"), + exec_approval(2, "exec", Some("approval"), "turn"), + patch_approval(3, "patch", "turn"), + permissions_approval(4, "permissions", "turn"), + elicitation("elicitation"), + ]; + + for request in &requests { + state.note_server_request(request); + assert!(state.should_replay_snapshot_request(request)); + } + assert!(state.has_pending_thread_approvals()); + assert!(state.has_pending_thread_user_input()); + } + + #[test] + fn outbound_response_clears_only_the_matching_request() { + let mut state = PendingInteractiveReplayState::default(); + let first = request_user_input(1, "input-1", "turn"); + let second = request_user_input(2, "input-2", "turn"); + state.note_server_request(&first); + state.note_server_request(&second); + + state.note_outbound_response(&RequestId::Integer(1)); + + assert!(!state.should_replay_snapshot_request(&first)); + assert!(state.should_replay_snapshot_request(&second)); + assert!(state.has_pending_thread_user_input()); + } + + #[test] + fn server_resolution_clears_request_identity_and_category() { + let mut state = PendingInteractiveReplayState::default(); + let request = exec_approval(1, "item", Some("approval"), "turn"); + state.note_server_request(&request); + state.note_server_notification(&resolved(RequestId::Integer(1))); + + assert!(!state.should_replay_snapshot_request(&request)); + assert!(!state.has_pending_thread_approvals()); + } + + #[test] + fn item_started_clears_command_and_file_approval_by_item_id() { + let mut state = PendingInteractiveReplayState::default(); + let exec = exec_approval(1, "exec-item", Some("approval-id"), "turn"); + let patch = patch_approval(2, "patch-item", "turn"); + state.note_server_request(&exec); + state.note_server_request(&patch); + + state.note_server_notification(&ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + started_at_ms: 0, + item: ThreadItem::CommandExecution { + id: "exec-item".to_string(), + metadata: None, + plugin_id: None, + script_path: None, + command: "echo ok".to_string(), + cwd: "/tmp".to_string(), + process_id: None, + source: Default::default(), + status: app_server_protocol::protocol::v2::CommandExecutionStatus::InProgress, + command_actions: Vec::new(), + aggregated_output: None, + exit_code: None, + duration_ms: None, + terminal_interactions: Vec::new(), + }, + })); + assert!(!state.should_replay_snapshot_request(&exec)); + assert!(state.should_replay_snapshot_request(&patch)); + + state.note_server_notification(&ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + started_at_ms: 0, + item: ThreadItem::FileChange { + id: "patch-item".to_string(), + metadata: None, + changes: Vec::new(), + status: app_server_protocol::protocol::v2::PatchApplyStatus::InProgress, + }, + })); + assert!(!state.should_replay_snapshot_request(&patch)); + } + + #[test] + fn turn_completion_clears_all_turn_indexed_prompts() { + let mut state = PendingInteractiveReplayState::default(); + let requests = [ + request_user_input(1, "input", "turn"), + exec_approval(2, "exec", None, "turn"), + patch_approval(3, "patch", "turn"), + permissions_approval(4, "permissions", "turn"), + ]; + for request in &requests { + state.note_server_request(request); + } + + state.note_server_notification(&turn_completed("turn")); + + assert!(requests + .iter() + .all(|request| !state.should_replay_snapshot_request(request))); + assert!(!state.has_pending_thread_approvals()); + assert!(!state.has_pending_thread_user_input()); + } + + #[test] + fn user_input_requests_are_removed_in_fifo_order_per_turn() { + let mut state = PendingInteractiveReplayState::default(); + let first = request_user_input(1, "first", "turn"); + let second = request_user_input(2, "second", "turn"); + state.note_server_request(&first); + state.note_server_request(&second); + + state.note_outbound_response(&RequestId::Integer(1)); + + assert!(!state.should_replay_snapshot_request(&first)); + assert!(state.should_replay_snapshot_request(&second)); + } + + #[test] + fn evicted_request_does_not_replay() { + let mut state = PendingInteractiveReplayState::default(); + let request = permissions_approval(1, "permissions", "turn"); + state.note_server_request(&request); + state.note_evicted_server_request(&request); + + assert!(!state.should_replay_snapshot_request(&request)); + assert!(!state.has_pending_thread_approvals()); + } + + #[test] + fn closing_thread_clears_all_prompt_categories() { + let mut state = PendingInteractiveReplayState::default(); + let requests = [ + request_user_input(1, "input", "turn"), + exec_approval(2, "exec", None, "turn"), + patch_approval(3, "patch", "turn"), + permissions_approval(4, "permissions", "turn"), + elicitation("elicitation"), + ]; + for request in &requests { + state.note_server_request(request); + } + + state.note_server_notification(&ServerNotification::ThreadClosed( + ThreadClosedNotification { + thread_id: "thread".to_string(), + }, + )); + + assert!(requests + .iter() + .all(|request| !state.should_replay_snapshot_request(request))); + assert!(!state.has_pending_thread_approvals()); + assert!(!state.has_pending_thread_user_input()); + } +} diff --git a/lime-rs/crates/tui/src/app/reconnect.rs b/lime-rs/crates/tui/src/app/reconnect.rs new file mode 100644 index 000000000..2e3a40583 --- /dev/null +++ b/lime-rs/crates/tui/src/app/reconnect.rs @@ -0,0 +1,96 @@ +//! App Server reconnection lifecycle for the TUI app. +//! +//! The reconnect owner lives under `app/` to match Codex. Transport creation remains delegated +//! to the shared runtime boundary; this module only restores the canonical thread session and +//! its server-backed settings. + +use std::time::Duration; + +use anyhow::{bail, Result}; +use app_server_protocol::protocol::v2::Thread; + +use crate::app_server_session::AppServerSession; +use crate::runtime::{connect_session, TuiOptions}; + +const RECONNECT_DELAYS: [Duration; 4] = [ + Duration::ZERO, + Duration::from_millis(100), + Duration::from_millis(300), + Duration::from_secs(1), +]; + +/// A successfully reattached App Server session and its canonical thread snapshot. +pub(crate) struct ReconnectedSession { + pub(crate) session: AppServerSession, + pub(crate) thread: Thread, + pub(crate) permission_profiles: Vec, +} + +pub(crate) async fn reconnect_session( + options: TuiOptions, + thread_id: String, + model: Option, + model_provider: Option, + effort: Option, + permissions: Option, +) -> Result { + let mut last_error = None; + for delay in RECONNECT_DELAYS { + if !delay.is_zero() { + tokio::time::sleep(delay).await; + } + let mut candidate = match connect_session(&options).await { + Ok(session) => session, + Err(error) => { + last_error = Some(error); + continue; + } + }; + match candidate.resume_thread(thread_id.clone()).await { + Ok(response) => { + let permission_profiles = candidate + .list_permission_profiles(Some(response.cwd.clone())) + .await; + let permission_profiles = match permission_profiles { + Ok(response) => response, + Err(error) => { + let _ = candidate.shutdown().await; + last_error = Some(error); + continue; + } + }; + if let Err(error) = candidate + .update_settings( + model.clone(), + model_provider.clone(), + effort.clone(), + permissions.clone(), + ) + .await + { + let _ = candidate.shutdown().await; + last_error = Some(error); + continue; + } + return Ok(ReconnectedSession { + session: candidate, + thread: response.thread, + permission_profiles: permission_profiles + .data + .into_iter() + .filter(|profile| profile.allowed) + .map(|profile| profile.id) + .collect(), + }); + } + Err(error) => { + let _ = candidate.shutdown().await; + last_error = Some(error); + } + } + } + let error = last_error + .map(|error| format!("{error:#}")) + .unwrap_or_else(|| "unknown reconnect failure".to_string()); + bail!("failed to reconnect App Server and resume thread {thread_id}: {error}") +} diff --git a/lime-rs/crates/tui/src/app/replay_filter.rs b/lime-rs/crates/tui/src/app/replay_filter.rs new file mode 100644 index 000000000..bf443cd82 --- /dev/null +++ b/lime-rs/crates/tui/src/app/replay_filter.rs @@ -0,0 +1,181 @@ +//! Helpers for deciding which buffered events to replay when switching threads. +//! +//! The live event store remains untouched. These filters only normalize the snapshot that is +//! about to be replayed into the active TUI projection. + +use std::collections::HashSet; + +use app_server_protocol::protocol::v2::{ServerNotification, ThreadItem}; + +use super::thread_events::{ThreadBufferedEvent, ThreadEventSnapshot}; + +#[cfg_attr(not(test), allow(dead_code))] +pub(super) fn snapshot_has_pending_interactive_request(snapshot: &ThreadEventSnapshot) -> bool { + snapshot.events.iter().any(|event| { + matches!( + event, + ThreadBufferedEvent::Request(request) + if matches!( + request.as_ref(), + app_server_protocol::protocol::v2::ServerRequest::ItemCommandExecutionRequestApproval { .. } + | app_server_protocol::protocol::v2::ServerRequest::ItemFileChangeRequestApproval { .. } + | app_server_protocol::protocol::v2::ServerRequest::McpServerElicitationRequest { .. } + | app_server_protocol::protocol::v2::ServerRequest::ItemPermissionsRequestApproval { .. } + | app_server_protocol::protocol::v2::ServerRequest::ItemToolRequestUserInput { .. } + ) + ) + }) +} + +#[cfg_attr(not(test), allow(dead_code))] +pub(super) fn event_is_notice(event: &ThreadBufferedEvent) -> bool { + matches!( + event, + ThreadBufferedEvent::Notification(notification) + if matches!( + notification.as_ref(), + ServerNotification::Warning(_) + | ServerNotification::GuardianWarning(_) + | ServerNotification::ConfigWarning(_) + ) + ) +} + +/// A completed item's full text replaces its earlier streaming deltas during replay. +/// +/// Keep deltas without a later completion so an in-progress or truncated stream still renders. +/// Other events are barriers: streaming text may need to flush before a tool or prompt. This only +/// changes the replay snapshot; the live notification store remains untouched. +pub(super) fn omit_completed_agent_deltas(events: &mut Vec) { + let mut completed = HashSet::new(); + events.reverse(); + events.retain(|event| { + if let ThreadBufferedEvent::Notification(notification) = event { + match notification.as_ref() { + ServerNotification::ItemCompleted(notification) => { + if let ThreadItem::AgentMessage { id, .. } = ¬ification.item { + completed.insert(( + notification.thread_id.clone(), + notification.turn_id.clone(), + id.clone(), + )); + } else { + completed.clear(); + } + } + ServerNotification::AgentMessageDelta(notification) => { + return !completed.contains(&( + notification.thread_id.clone(), + notification.turn_id.clone(), + notification.item_id.clone(), + )); + } + _ => completed.clear(), + } + } else { + completed.clear(); + } + true + }); + events.reverse(); +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::{ + AgentMessageDeltaNotification, ConfigWarningNotification, ItemCompletedNotification, + ServerRequest, ThreadItem, ToolRequestUserInputParams, WarningNotification, + }; + use app_server_protocol::RequestId; + + fn delta(item_id: &str, text: &str) -> ThreadBufferedEvent { + ThreadBufferedEvent::Notification(Box::new(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: item_id.to_string(), + delta: text.to_string(), + }, + ))) + } + + fn completed_agent(item_id: &str, text: &str) -> ThreadBufferedEvent { + ThreadBufferedEvent::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + completed_at_ms: 1, + item: ThreadItem::AgentMessage { + id: item_id.to_string(), + metadata: None, + text: text.to_string(), + phase: None, + memory_citation: None, + delivery: None, + }, + }, + ))) + } + + #[test] + fn snapshot_has_pending_interactive_request_matches_user_input() { + let request = ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(1), + params: ToolRequestUserInputParams { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + questions: Vec::new(), + auto_resolution_ms: None, + }, + }; + let snapshot = ThreadEventSnapshot { + events: vec![ThreadBufferedEvent::Request(Box::new(request))], + }; + assert!(snapshot_has_pending_interactive_request(&snapshot)); + } + + #[test] + fn event_is_notice_matches_warning_notifications() { + let warning = ThreadBufferedEvent::Notification(Box::new(ServerNotification::Warning( + WarningNotification { + thread_id: Some("thread".to_string()), + message: "warning".to_string(), + code: None, + }, + ))); + let config_warning = ThreadBufferedEvent::Notification(Box::new( + ServerNotification::ConfigWarning(ConfigWarningNotification { + summary: "config".to_string(), + details: None, + path: None, + range: None, + }), + )); + assert!(event_is_notice(&warning)); + assert!(event_is_notice(&config_warning)); + assert!(!event_is_notice(&delta("item", "text"))); + } + + #[test] + fn omit_completed_agent_deltas_removes_only_replaced_streams() { + let mut events = vec![delta("item-1", "draft"), completed_agent("item-1", "final")]; + events.push(delta("item-2", "still streaming")); + + omit_completed_agent_deltas(&mut events); + + assert_eq!(events.len(), 2); + assert!(matches!( + &events[0], + ThreadBufferedEvent::Notification(notification) + if matches!(notification.as_ref(), ServerNotification::ItemCompleted(_)) + )); + assert!(matches!( + &events[1], + ThreadBufferedEvent::Notification(notification) + if matches!(notification.as_ref(), ServerNotification::AgentMessageDelta(delta) + if delta.item_id == "item-2") + )); + } +} diff --git a/lime-rs/crates/tui/src/app/session_lifecycle.rs b/lime-rs/crates/tui/src/app/session_lifecycle.rs new file mode 100644 index 000000000..1a7b9f61c --- /dev/null +++ b/lime-rs/crates/tui/src/app/session_lifecycle.rs @@ -0,0 +1,59 @@ +//! Session and subagent selection lifecycle for the TUI app. + +use super::*; +use crate::app_server_session::AppServerSession; + +impl App { + /// Resume a canonical App Server thread and refresh the local projection/settings snapshot. + /// + /// Thread identity, queued input and agent-overview replay all move together so callers do + /// not accidentally switch only the transport target or only the rendered conversation. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn resume_target_session( + &mut self, + session: &mut AppServerSession, + thread_id: String, + model: &mut Option, + model_provider: &mut Option, + effort: &mut Option, + permissions: &mut Option, + options: &crate::runtime::TuiOptions, + ) -> anyhow::Result<()> { + if self.thread_id.as_deref() == Some(thread_id.as_str()) { + return Ok(()); + } + let response = session.resume_thread(thread_id).await?; + let resumed_thread_id = response.thread.id.clone(); + let snapshot = self.take_thread_event_snapshot(&resumed_thread_id, true); + self.bottom_pane.clear(); + self.hydrate_thread(response.thread); + self.set_thread_id(resumed_thread_id.clone()); + self.restore_thread_input(&resumed_thread_id); + self.replay_thread_snapshot(snapshot); + *model = Some(response.model); + *model_provider = Some(response.model_provider); + *effort = response.reasoning_effort; + if options.permissions.is_none() { + *permissions = session.active_permission_profile().map(str::to_string); + } + self.set_cwd(PathBuf::from(response.cwd)); + self.set_settings( + model.clone(), + model_provider.clone(), + effort.clone(), + permissions.clone(), + ); + self.refresh_queued_submissions(session).await; + Ok(()) + } + + pub(super) fn open_agent_picker(&mut self) { + let picker = + AgentPicker::from_navigation(&self.agent_navigation, self.primary_thread_id.as_deref()); + if picker.is_empty() { + self.projection.set_status("no sub-agents available"); + } else { + self.agent_picker = Some(picker); + } + } +} diff --git a/lime-rs/crates/tui/src/app/tests.rs b/lime-rs/crates/tui/src/app/tests.rs new file mode 100644 index 000000000..511ee2fb4 --- /dev/null +++ b/lime-rs/crates/tui/src/app/tests.rs @@ -0,0 +1,864 @@ +use super::*; +use app_server_protocol::protocol::v2::{ + CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, ServerRequest, + UserInput, +}; +use app_server_protocol::RequestId; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn dispatch_connected_input(app: &mut App, event: Event) -> AppAction { + app.handle_tui_event(to_tui_event(event), true) +} + +fn dispatch_disconnected_input(app: &mut App, event: Event) -> AppAction { + app.handle_tui_event(to_tui_event(event), false) +} + +fn to_tui_event(event: Event) -> TuiEvent { + match event { + Event::Key(key) => TuiEvent::Key(key), + Event::Paste(text) => TuiEvent::Paste(text), + Event::Resize(width, height) => TuiEvent::Resize(ratatui::layout::Size { width, height }), + Event::FocusGained => TuiEvent::FocusGained, + Event::FocusLost => TuiEvent::FocusLost, + _ => TuiEvent::Draw, + } +} + +#[test] +fn active_bottom_pane_receives_input_before_the_chat_composer() { + let mut app = App::default(); + app.composer.insert("draft"); + app.bottom_pane + .enqueue(ServerRequest::ItemCommandExecutionRequestApproval { + id: RequestId::Integer(7), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "command-1".to_string(), + started_at_ms: 1, + approval_id: None, + reason: None, + network_approval_context: None, + command: Some("cargo test".to_string()), + cwd: Some("/workspace".to_string()), + available_decisions: None, + }, + }) + .expect("queue approval"); + + let ignored = dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ); + assert_eq!(ignored, AppAction::None); + assert_eq!(app.composer.text(), "draft"); + + let response = dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ); + assert!(matches!( + response, + AppAction::Respond(AppServerResponse::Command { + response: app_server_protocol::protocol::v2::CommandExecutionRequestApprovalResponse { + decision: CommandExecutionApprovalDecision::Accept, + }, + .. + }) + )); + assert!(!app.bottom_pane.is_active()); + assert_eq!(app.composer.text(), "draft"); +} + +#[test] +fn tab_queues_a_follow_up_without_submitting_the_active_turn() { + let mut app = App::default(); + app.composer.insert("follow up"); + app.projection.apply( + app_server_protocol::protocol::v2::ServerNotification::TurnStarted( + app_server_protocol::protocol::v2::TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: app_server_protocol::protocol::v2::Turn { + id: "turn-1".to_string(), + items: Vec::new(), + items_view: Default::default(), + status: app_server_protocol::protocol::v2::TurnStatus::InProgress, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + ), + ); + + let action = dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), + ); + + assert_eq!(action, AppAction::Queue("follow up".to_string())); + assert!(app.composer.is_empty()); +} + +#[test] +fn escape_interrupts_only_an_active_turn_and_preserves_the_draft() { + let mut app = App::default(); + app.composer.insert("keep this draft"); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::None + ); + + app.start_turn("turn-1".to_string()); + assert!(app.active_turn_elapsed(Instant::now()).is_some()); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::Interrupt + ); + assert_eq!(app.composer.text(), "keep this draft"); +} + +#[test] +fn turn_completion_clears_the_active_status_timer() { + use app_server_protocol::protocol::v2::{ + Turn, TurnCompletedNotification, TurnItemsView, TurnStatus, + }; + + let mut app = App::default(); + app.set_thread_id("thread-1".to_string()); + app.start_turn("turn-1".to_string()); + app.apply_notification(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-1".to_string(), + items: Vec::new(), + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1), + }, + }, + )); + + assert!(app.projection.active_turn_id().is_none()); + assert!(app.active_turn_elapsed(Instant::now()).is_none()); +} + +#[test] +fn permission_profile_catalog_is_trimmed_deduplicated_and_used_for_cycles() { + let mut app = App::default(); + app.set_permission_profiles([ + " custom-read ".to_string(), + "custom-write".to_string(), + "custom-read".to_string(), + "".to_string(), + ]); + + assert_eq!( + app.permission_profiles, + vec!["custom-read".to_string(), "custom-write".to_string()] + ); + assert_eq!( + app.cycle_permission_profile(Some("custom-read"), 1), + "custom-write" + ); + assert_eq!( + app.cycle_permission_profile(Some("custom-write"), 1), + "custom-read" + ); + + app.set_permission_profiles([" ".to_string(), "custom-read".to_string()]); + assert_eq!(app.permission_profiles, vec!["custom-read".to_string()]); + app.set_permission_profiles(std::iter::empty::()); + assert!(app.permission_profiles.is_empty()); + assert_eq!( + app.cycle_permission_profile(Some(":read-only"), 1), + ":workspace" + ); +} + +#[test] +fn backtab_cycles_server_collaboration_modes_only_when_idle() { + let mut app = App::default(); + app.set_settings( + Some("fixture-model".to_string()), + Some("fixture-provider".to_string()), + Some("medium".to_string()), + None, + ); + app.set_collaboration_modes(vec![ + app_server_protocol::protocol::v2::CollaborationModeMask { + name: "Plan".to_string(), + mode: Some(agent_protocol::ModeKind::Plan), + model: None, + reasoning_effort: Some(Some("high".to_string())), + }, + app_server_protocol::protocol::v2::CollaborationModeMask { + name: "Default".to_string(), + mode: Some(agent_protocol::ModeKind::Default), + model: None, + reasoning_effort: Some(None), + }, + ]); + + assert_eq!( + app.collaboration_mode.as_ref().map(|mode| mode.mode), + Some(agent_protocol::ModeKind::Default) + ); + let plan_mode = agent_protocol::CollaborationMode { + mode: agent_protocol::ModeKind::Plan, + settings: agent_protocol::CollaborationModeSettings { + model: "fixture-model".to_string(), + reasoning_effort: Some("high".to_string()), + developer_instructions: None, + }, + }; + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,)) + ), + AppAction::ChangeCollaborationMode(plan_mode.clone()) + ); + app.collaboration_mode = Some(plan_mode); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,)) + ), + AppAction::ChangeCollaborationMode(agent_protocol::CollaborationMode { + mode: agent_protocol::ModeKind::Default, + settings: agent_protocol::CollaborationModeSettings { + model: "fixture-model".to_string(), + reasoning_effort: None, + developer_instructions: None, + }, + }) + ); + + app.start_turn("turn-1".to_string()); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE,)) + ), + AppAction::None + ); +} + +#[test] +fn settings_updates_keep_the_active_collaboration_mode_in_sync() { + let mut app = App::default(); + app.collaboration_mode = Some(agent_protocol::CollaborationMode { + mode: agent_protocol::ModeKind::Plan, + settings: agent_protocol::CollaborationModeSettings { + model: "old-model".to_string(), + reasoning_effort: Some("high".to_string()), + developer_instructions: None, + }, + }); + + app.set_settings( + Some("new-model".to_string()), + Some("fixture-provider".to_string()), + Some("low".to_string()), + None, + ); + + let mode = app.collaboration_mode.as_ref().expect("active mode"); + assert_eq!(mode.settings.model, "new-model"); + assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("low")); + + app.set_settings( + Some("newer-model".to_string()), + Some("fixture-provider".to_string()), + None, + None, + ); + let mode = app.collaboration_mode.as_ref().expect("active mode"); + assert_eq!(mode.settings.model, "newer-model"); + assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("low")); +} + +#[test] +fn an_open_popup_owns_escape_before_active_turn_interruption() { + let mut app = App::default(); + app.start_turn("turn-1".to_string()); + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)), + ); + assert!(app.command_popup.is_some()); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert!(app.command_popup.is_none()); + assert!(app.projection.active_turn_id().is_some()); +} + +#[test] +fn history_search_owns_escape_before_active_turn_interruption() { + let mut app = App::default(); + app.composer.load_history(["previous prompt".to_string()]); + app.composer.insert("previous"); + app.start_turn("turn-1".to_string()); + + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)), + ); + assert!(app.composer.history_search_active()); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert!(!app.composer.history_search_active()); + assert_eq!(app.composer.text(), "previous"); + assert!(app.projection.active_turn_id().is_some()); +} + +#[test] +fn codex_style_effort_and_permission_shortcuts_are_not_inserted_into_draft() { + let mut app = App::default(); + app.composer.insert("draft"); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('.'), KeyModifiers::ALT,)) + ), + AppAction::IncreaseEffort + ); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE,)) + ), + AppAction::NextPermissions + ); + assert_eq!(app.composer.text(), "draft"); +} + +#[test] +fn copy_shortcut_and_slash_command_do_not_become_turn_input() { + let mut app = App::default(); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL,)) + ), + AppAction::CopyLastResponse + ); + app.composer.insert("/copy"); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::CopyLastResponse + ); + assert!(app.composer.is_empty()); +} + +#[test] +fn slash_popup_filters_and_executes_immediate_commands() { + let mut app = App::default(); + for character in ['/', 'm'] { + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE,)) + ), + AppAction::None + ); + } + assert_eq!( + app.command_popup.as_ref().and_then(CommandPopup::selected), + Some(SlashCommand::Model) + ); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::Submit("/model".to_string()) + ); + assert!(app.command_popup.is_none()); + assert!(app.composer.is_empty()); +} + +#[test] +fn slash_popup_completes_argument_commands_and_reopens_after_cancelled_input_changes() { + let mut app = App::default(); + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)), + ); + assert!(app.command_popup.is_some()); + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)), + ); + assert!(app.command_popup.is_none()); + + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('e'), KeyModifiers::NONE)), + ); + assert_eq!( + app.command_popup.as_ref().and_then(CommandPopup::selected), + Some(SlashCommand::Effort) + ); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert_eq!(app.composer.text(), "/effort "); + assert!(app.command_popup.is_none()); +} + +#[test] +fn status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed() { + let mut app = App::default(); + app.composer.insert("real prompt"); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::Submit("real prompt".to_string()) + ); + app.set_thread_id("thread-1".to_string()); + app.set_settings( + Some("gpt-5".to_string()), + Some("openai".to_string()), + Some("high".to_string()), + Some(":workspace".to_string()), + ); + app.composer.insert("/status"); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert!(app.pager_overlay.is_some()); + assert!(app.composer.is_empty()); + + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)), + ); + assert!(app.composer.is_empty()); + assert!(app.pager_overlay.is_some()); + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)), + ); + assert!(app.pager_overlay.is_none()); + + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)), + ); + assert_eq!(app.composer.text(), "real prompt"); + assert!(app.projection.entries().is_empty()); +} + +#[test] +fn ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state() { + let mut app = App::default(); + app.composer.insert("draft"); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL,)) + ), + AppAction::None + ); + assert!(app + .pager_overlay + .as_ref() + .is_some_and(PagerOverlay::is_transcript)); + assert_eq!(app.composer.text(), "draft"); + assert!(app.projection.entries().is_empty()); + + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)), + ); + assert!(app.pager_overlay.is_none()); + assert_eq!(app.composer.text(), "draft"); +} + +#[test] +fn image_shortcut_attaches_and_allows_image_only_submission() { + let mut app = App::default(); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL,)) + ), + AppAction::PasteImage + ); + app.attach_image(PathBuf::from("/tmp/input.png")); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::Submit(String::new()) + ); + assert_eq!( + app.take_pending_images(), + vec![PathBuf::from("/tmp/input.png")] + ); +} + +#[test] +fn failed_image_submission_can_restore_pending_attachments() { + let mut app = App::default(); + app.attach_image(PathBuf::from("/tmp/one.png")); + app.attach_image(PathBuf::from("/tmp/two.png")); + + let images = app.take_pending_images(); + assert!(!app.composer.has_pending_images()); + app.restore_pending_images(images); + + assert_eq!( + app.composer.pending_images(), + &[PathBuf::from("/tmp/one.png"), PathBuf::from("/tmp/two.png")] + ); +} + +#[test] +fn queued_submission_projection_updates_by_id_and_clears_on_thread_change() { + let queued = |id: &str, text: &str| QueuedSubmission { + id: id.to_string(), + input: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + client_user_message_id: format!("client-{id}"), + }; + let mut app = App::default(); + app.set_thread_id("thread-1".to_string()); + app.upsert_queued_submission(queued("queue-1", "first")); + app.upsert_queued_submission(queued("queue-1", "revised")); + app.upsert_queued_submission(queued("queue-2", "second")); + + assert_eq!(app.queued_submissions.len(), 2); + assert!(matches!( + app.queued_submissions[0].input.as_slice(), + [UserInput::Text { text, .. }] if text == "revised" + )); + + app.set_thread_id("thread-2".to_string()); + assert!(app.queued_submissions.is_empty()); +} + +#[test] +fn alt_up_requests_server_delete_before_restoring_the_last_queued_input() { + let submission = QueuedSubmission { + id: "queue-1".to_string(), + input: vec![ + UserInput::LocalImage { + detail: None, + path: "/tmp/queued.png".to_string(), + }, + UserInput::Text { + text: "revise this follow-up".to_string(), + text_elements: Vec::new(), + }, + ], + client_user_message_id: "client-queue-1".to_string(), + }; + let mut app = App::default(); + app.set_queued_submissions(vec![submission.clone()]); + + let action = dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)), + ); + + assert_eq!(action, AppAction::EditQueuedSubmission(submission.clone())); + assert_eq!(app.queued_submissions, vec![submission.clone()]); + assert!(app.composer.is_empty()); + assert!(!app.composer.has_pending_images()); + + assert!(app.restore_queued_submission_for_edit(submission)); + assert!(app.queued_submissions.is_empty()); + assert_eq!(app.composer.text(), "revise this follow-up"); + assert_eq!( + app.composer.pending_images(), + &[PathBuf::from("/tmp/queued.png")] + ); +} + +#[test] +fn alt_up_does_not_offer_a_lossy_or_overwriting_queue_edit() { + let remote_image = QueuedSubmission { + id: "queue-remote".to_string(), + input: vec![UserInput::Image { + detail: None, + url: "https://example.test/input.png".to_string(), + }], + client_user_message_id: "client-remote".to_string(), + }; + let mut app = App::default(); + app.set_queued_submissions(vec![remote_image]); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,)) + ), + AppAction::None + ); + + app.set_queued_submissions(vec![QueuedSubmission { + id: "queue-text".to_string(), + input: vec![UserInput::Text { + text: "queued".to_string(), + text_elements: Vec::new(), + }], + client_user_message_id: "client-text".to_string(), + }]); + app.composer.insert("unsent draft"); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT,)) + ), + AppAction::None + ); + assert_eq!(app.composer.text(), "unsent draft"); + assert_eq!(app.queued_submissions.len(), 1); +} + +#[test] +fn disconnected_input_edits_locally_without_submit_or_queue() { + let mut app = App::default(); + app.composer.insert("draft"); + + assert_eq!( + dispatch_disconnected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('!'), KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert_eq!( + dispatch_disconnected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert_eq!(app.composer.text(), "draft!"); + assert!(app.queued_submissions.is_empty()); +} + +#[test] +fn disconnected_paste_and_ctrl_c_are_handled_at_the_app_boundary() { + let mut app = App::default(); + assert_eq!( + dispatch_disconnected_input(&mut app, Event::Paste("离线草稿".to_string())), + AppAction::None + ); + assert_eq!(app.composer.text(), "离线草稿"); + assert_eq!( + dispatch_disconnected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL,)) + ), + AppAction::Quit + ); +} + +#[test] +fn resume_picker_owns_input_until_cancelled() { + let mut app = App::default(); + app.resume_picker = Some(PickerState::new( + Vec::new(), + crate::resume_picker::SessionPickerAction::Resume, + crate::resume_picker::SessionStatus::Active, + None, + true, + )); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE,)) + ), + AppAction::ResumePicker(PickerAction::Reload) + ); + assert!(app.resume_picker.is_some()); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::ResumePicker(PickerAction::Reload) + ); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE,)) + ), + AppAction::None + ); + assert!(app.resume_picker.is_none()); +} + +#[test] +fn alt_right_switches_to_the_next_agent_in_spawn_order() { + let mut app = App::default(); + app.set_thread_id("main".to_string()); + app.agent_navigation + .upsert("agent-1", Some("Robie".to_string()), None, false); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT,)) + ), + AppAction::SwitchThread("agent-1".to_string()) + ); +} + +#[test] +fn subagents_slash_command_opens_the_codex_named_picker() { + let mut app = App::default(); + app.set_thread_id("main".to_string()); + app.agent_navigation.upsert( + "agent-1", + Some("Robie".to_string()), + Some("worker".to_string()), + false, + ); + app.composer.insert("/subagents"); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)), + ), + AppAction::None + ); + assert!(app.agents_overview.is_none()); + assert!(app.agent_picker.is_some()); + assert!(app.composer.is_empty()); +} + +#[test] +fn agents_slash_command_opens_the_agents_overview() { + let mut app = App::default(); + app.composer.insert("/agents"); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ), + AppAction::RefreshAgentsOverview + ); + assert!(app.agents_overview.is_some()); + assert!(app.agent_picker.is_none()); + assert!(app.composer.is_empty()); +} + +#[test] +fn resume_slash_command_opens_the_shared_picker_action() { + let mut app = App::default(); + app.composer.insert("/resume"); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE,)) + ), + AppAction::OpenResumePicker + ); + assert!(app.composer.is_empty()); +} + +#[test] +fn subagent_activity_marks_the_thread_parent_owned_before_resume() { + let mut app = App::default(); + app.set_thread_id("agent-1".to_string()); + app.apply_notification(ServerNotification::ItemStarted( + app_server_protocol::protocol::v2::ItemStartedNotification { + item: app_server_protocol::protocol::v2::ThreadItem::SubAgentActivity { + id: "activity-1".to_string(), + metadata: None, + kind: app_server_protocol::protocol::v2::SubAgentActivityKind::Started, + agent_thread_id: "agent-1".to_string(), + agent_path: "/root/worker".to_string(), + }, + thread_id: "main".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1, + }, + )); + + assert!(!app.can_accept_direct_input()); + assert_eq!(app.projection.status(), "sub-agent thread is parent-owned"); +} + +#[test] +fn sub_agent_activity_updates_navigation_liveness_and_label() { + let mut app = App::default(); + app.set_thread_id("main".to_string()); + app.apply_notification(ServerNotification::ItemStarted( + app_server_protocol::protocol::v2::ItemStartedNotification { + item: app_server_protocol::protocol::v2::ThreadItem::SubAgentActivity { + id: "activity-1".to_string(), + metadata: None, + kind: app_server_protocol::protocol::v2::SubAgentActivityKind::Started, + agent_thread_id: "agent-1".to_string(), + agent_path: "/root/worker".to_string(), + }, + thread_id: "main".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1, + }, + )); + + assert!( + app.agent_navigation + .get("agent-1") + .expect("activity creates picker row") + .is_running + ); + assert_eq!( + app.agent_navigation + .active_agent_label(Some("agent-1"), Some("main")), + Some("`/root/worker`".to_string()) + ); +} diff --git a/lime-rs/crates/tui/src/app/thread_event_buffer.rs b/lime-rs/crates/tui/src/app/thread_event_buffer.rs new file mode 100644 index 000000000..c788eae5a --- /dev/null +++ b/lime-rs/crates/tui/src/app/thread_event_buffer.rs @@ -0,0 +1,99 @@ +//! Bounded replay buffer for notifications and interactive requests by thread. +//! +//! The App Server stream is shared by all threads visible to the TUI. When an agent overview or +//! a background child thread is active, events for another thread must remain ordered until that +//! thread is resumed. This owner keeps the buffer finite and never stores requests it cannot +//! replay safely. + +use std::borrow::Cow; + +use app_server_protocol::protocol::v2::ServerNotification; + +use super::thread_events::{ThreadBufferedEvent, ThreadEventStore}; + +/// Maximum number of events retained for one thread. +pub(crate) const THREAD_EVENT_CHANNEL_CAPACITY: usize = 256; +const MAX_COALESCED_AGENT_MESSAGE_DELTA_BYTES: usize = 4 * 1024; +const MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES: usize = 256 * 1024; + +impl ThreadEventStore { + pub(super) fn push_replay_notification(&mut self, notification: Cow<'_, ServerNotification>) { + if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() { + if delta.delta.len() > MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES { + return; + } + let can_coalesce = match self.buffer.back() { + Some(ThreadBufferedEvent::Notification(previous)) => match previous.as_ref() { + ServerNotification::AgentMessageDelta(previous) => { + previous.thread_id == delta.thread_id + && previous.turn_id == delta.turn_id + && previous.item_id == delta.item_id + && previous.delta.len().saturating_add(delta.delta.len()) + <= MAX_COALESCED_AGENT_MESSAGE_DELTA_BYTES + } + _ => false, + }, + _ => false, + }; + if can_coalesce { + if let Some(ThreadBufferedEvent::Notification(previous)) = self.buffer.back_mut() { + if let ServerNotification::AgentMessageDelta(previous) = previous.as_mut() { + previous.delta.push_str(&delta.delta); + self.buffered_agent_message_delta_bytes = self + .buffered_agent_message_delta_bytes + .saturating_add(delta.delta.len()); + self.evict_overflowing_events(); + return; + } + } + } + } + + self.push_buffered_event(ThreadBufferedEvent::Notification(Box::new( + notification.into_owned(), + ))); + } + + pub(super) fn push_buffered_event(&mut self, event: ThreadBufferedEvent) -> bool { + if matches!(event, ThreadBufferedEvent::Request(_)) && self.buffer.len() >= self.capacity { + return false; + } + if let ThreadBufferedEvent::Notification(notification) = &event { + if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() { + self.buffered_agent_message_delta_bytes = self + .buffered_agent_message_delta_bytes + .saturating_add(delta.delta.len()); + } + } + self.buffer.push_back(event); + self.evict_overflowing_events(); + true + } + + fn evict_overflowing_events(&mut self) { + while self.buffer.len() > self.capacity + || self.buffered_agent_message_delta_bytes > MAX_BUFFERED_AGENT_MESSAGE_DELTA_BYTES + { + let Some(index) = self + .buffer + .iter() + .position(|event| matches!(event, ThreadBufferedEvent::Notification(_))) + else { + break; + }; + let Some(ThreadBufferedEvent::Notification(notification)) = self.buffer.remove(index) + else { + continue; + }; + if let ServerNotification::AgentMessageDelta(delta) = notification.as_ref() { + self.buffered_agent_message_delta_bytes = self + .buffered_agent_message_delta_bytes + .saturating_sub(delta.delta.len()); + } + } + } +} + +#[cfg(test)] +#[path = "thread_event_buffer_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs b/lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs new file mode 100644 index 000000000..c2f4330b9 --- /dev/null +++ b/lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs @@ -0,0 +1,144 @@ +use super::*; +use app_server_protocol::protocol::v2::{ + AgentMessageDeltaNotification, ServerNotification, ServerRequest, + ServerRequestResolvedNotification, ThreadClosedNotification, Turn, TurnCompletedNotification, + TurnItemsView, TurnStatus, +}; +use app_server_protocol::RequestId; + +fn delta(thread_id: &str, turn_id: &str, item_id: &str, text: &str) -> ServerNotification { + ServerNotification::AgentMessageDelta(AgentMessageDeltaNotification { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.to_string(), + delta: text.to_string(), + }) +} + +#[test] +fn thread_event_store_coalesces_only_adjacent_matching_agent_message_deltas() { + let mut store = ThreadEventStore::new(/*capacity*/ 4); + store.push_notification(delta("thread", "turn", "item", "hello")); + store.push_notification(delta("thread", "turn", "item", " world")); + store.push_notification(delta("thread", "turn", "other", "bar")); + + let snapshot = store.snapshot(); + assert_eq!(snapshot.events.len(), 2); + let ThreadBufferedEvent::Notification(first) = &snapshot.events[0] else { + panic!("expected notification"); + }; + let ServerNotification::AgentMessageDelta(first) = first.as_ref() else { + panic!("expected agent delta"); + }; + assert_eq!(first.delta, "hello world"); +} + +#[test] +fn thread_event_store_preserves_requests_while_evicting_old_notifications() { + let mut store = ThreadEventStore::new(/*capacity*/ 4); + let request = ServerRequest::CurrentTimeRead { + id: RequestId::Integer(1), + params: app_server_protocol::protocol::v2::CurrentTimeReadParams { + thread_id: "thread".to_string(), + }, + }; + assert!(store.push_request(request)); + for index in 0..6 { + store.push_notification(delta("thread", "turn", &format!("item-{index}"), "x")); + } + + let snapshot = store.snapshot(); + assert_eq!(snapshot.events.len(), 4); + assert!(snapshot + .events + .iter() + .any(|event| matches!(event, ThreadBufferedEvent::Request(_)))); +} + +#[test] +fn thread_event_store_tracks_delta_bytes_after_coalescing() { + let mut store = ThreadEventStore::new(/*capacity*/ 4); + store.push_notification(delta("thread", "turn", "item", "a")); + store.push_notification(delta("thread", "turn", "item", "bc")); + assert_eq!(store.buffered_agent_message_delta_bytes, 3); +} + +fn user_input_request(id: i64, thread_id: &str) -> ServerRequest { + ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(id), + params: app_server_protocol::protocol::v2::ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn".to_string(), + item_id: format!("item-{id}"), + questions: Vec::new(), + auto_resolution_ms: None, + }, + } +} + +#[test] +fn thread_event_store_does_not_replay_server_resolved_request() { + let mut store = ThreadEventStore::new(4); + store.push_request(user_input_request(7, "thread")); + store.push_notification(ServerNotification::ServerRequestResolved( + ServerRequestResolvedNotification { + thread_id: "thread".to_string(), + request_id: RequestId::Integer(7), + }, + )); + + assert!(store + .snapshot() + .events + .iter() + .all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) })); +} + +#[test] +fn thread_event_store_drops_requests_when_turn_completes_or_thread_closes() { + let mut store = ThreadEventStore::new(4); + store.push_request(user_input_request(8, "thread")); + store.push_notification(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: Turn { + id: "turn".to_string(), + items: Vec::new(), + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }, + }, + )); + assert!(store + .snapshot() + .events + .iter() + .all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) })); + + store.push_request(user_input_request(9, "thread")); + store.push_notification(ServerNotification::ThreadClosed(ThreadClosedNotification { + thread_id: "thread".to_string(), + })); + assert!(store + .snapshot() + .events + .iter() + .all(|event| { !matches!(event, ThreadBufferedEvent::Request(_)) })); +} + +#[test] +fn thread_event_store_request_capacity_fails_closed_without_evicting_requests() { + let mut store = ThreadEventStore::new(1); + assert!(store.push_request(user_input_request(10, "thread"))); + assert!(!store.push_request(user_input_request(11, "thread"))); + let snapshot = store.snapshot(); + assert_eq!(snapshot.events.len(), 1); + assert!(matches!( + snapshot.events.first(), + Some(ThreadBufferedEvent::Request(request)) if request.id() == &RequestId::Integer(10) + )); +} diff --git a/lime-rs/crates/tui/src/app/thread_events.rs b/lime-rs/crates/tui/src/app/thread_events.rs new file mode 100644 index 000000000..373917e46 --- /dev/null +++ b/lime-rs/crates/tui/src/app/thread_events.rs @@ -0,0 +1,293 @@ +//! Thread-scoped event projection and agent activity observation. +//! +//! App Server transport delivery remains in `app_server_events.rs`; this owner applies canonical +//! Thread/Turn/Item notifications to the active projection and maintains navigation liveness. + +use std::borrow::Cow; +use std::collections::VecDeque; +use std::time::Instant; + +use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem}; + +use super::app_server_event_targets::{ + server_notification_thread_target, ServerNotificationThreadTarget, +}; +use super::pending_interactive_replay::PendingInteractiveReplayState; +use super::replay_filter; +use super::thread_event_buffer::THREAD_EVENT_CHANNEL_CAPACITY; +use super::App; + +#[derive(Debug, Clone)] +pub(super) struct ThreadEventSnapshot { + pub(super) events: Vec, +} + +#[derive(Debug, Clone)] +pub(super) enum ThreadBufferedEvent { + Notification(Box), + Request(Box), +} + +#[derive(Debug)] +pub(super) struct ThreadEventStore { + pub(super) buffer: VecDeque, + pub(super) capacity: usize, + pub(super) buffered_agent_message_delta_bytes: usize, + pub(super) pending_interactive_replay: PendingInteractiveReplayState, +} + +impl ThreadEventStore { + pub(super) fn new(capacity: usize) -> Self { + Self { + buffer: VecDeque::new(), + capacity, + buffered_agent_message_delta_bytes: 0, + pending_interactive_replay: PendingInteractiveReplayState::default(), + } + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn push_notification(&mut self, notification: ServerNotification) { + self.pending_interactive_replay + .note_server_notification(¬ification); + if matches!(notification, ServerNotification::ServerRequestResolved(_)) { + return; + } + self.push_replay_notification(Cow::Owned(notification)); + } + + pub(super) fn push_notification_ref(&mut self, notification: &ServerNotification) { + self.pending_interactive_replay + .note_server_notification(notification); + if matches!(notification, ServerNotification::ServerRequestResolved(_)) { + return; + } + self.push_replay_notification(Cow::Borrowed(notification)); + } + + pub(super) fn push_request(&mut self, request: ServerRequest) -> bool { + self.pending_interactive_replay + .note_server_request(&request); + if self.push_buffered_event(ThreadBufferedEvent::Request(Box::new(request.clone()))) { + true + } else { + self.pending_interactive_replay + .note_evicted_server_request(&request); + false + } + } + + /// A fresh `thread/resume` snapshot supersedes buffered projection events. Interactive + /// requests survive because they still need an explicit client response. + pub(super) fn rebase_buffer_after_session_refresh(&mut self) { + self.buffer.retain(|event| { + matches!(event, ThreadBufferedEvent::Request(request) + if self.pending_interactive_replay.should_replay_snapshot_request(request)) + }); + self.buffered_agent_message_delta_bytes = 0; + } + + pub(super) fn snapshot(&self) -> ThreadEventSnapshot { + ThreadEventSnapshot { + events: self + .buffer + .iter() + .filter(|event| match event { + ThreadBufferedEvent::Request(request) => self + .pending_interactive_replay + .should_replay_snapshot_request(request), + ThreadBufferedEvent::Notification(_) => true, + }) + .cloned() + .collect(), + } + } + + fn drain_snapshot(&mut self) -> ThreadEventSnapshot { + self.buffered_agent_message_delta_bytes = 0; + let mut snapshot = self.snapshot(); + replay_filter::omit_completed_agent_deltas(&mut snapshot.events); + self.buffer.clear(); + snapshot + } + + pub(super) fn note_outbound_response(&mut self, request_id: &app_server_protocol::RequestId) { + self.pending_interactive_replay + .note_outbound_response(request_id); + } +} + +#[derive(Debug)] +pub(super) struct ThreadEventChannel { + pub(super) store: ThreadEventStore, +} + +impl ThreadEventChannel { + pub(super) fn new(capacity: usize) -> Self { + Self { + store: ThreadEventStore::new(capacity), + } + } +} + +impl App { + pub(super) fn ensure_thread_channel(&mut self, thread_id: &str) -> &mut ThreadEventChannel { + self.thread_event_channels + .entry(thread_id.to_string()) + .or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY)) + } + + pub(super) fn replay_thread_snapshot(&mut self, snapshot: ThreadEventSnapshot) { + if replay_filter::snapshot_has_pending_interactive_request(&snapshot) { + self.pager_overlay = None; + } + for event in snapshot.events { + if replay_filter::event_is_notice(&event) { + continue; + } + self.handle_thread_event_replay(event); + } + } + + pub(super) fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) { + match event { + ThreadBufferedEvent::Notification(notification) => { + self.apply_notification(*notification) + } + ThreadBufferedEvent::Request(request) => { + if let Err(request) = self.bottom_pane.enqueue(*request) { + debug_assert!( + false, + "buffered request became unsupported: {}", + request.method() + ); + } else { + self.pager_overlay = None; + } + } + } + } + + pub(super) fn enqueue_thread_request( + &mut self, + thread_id: &str, + request: ServerRequest, + ) -> Result<(), Box> { + let channel = self.ensure_thread_channel(thread_id); + if channel.store.push_request(request.clone()) { + Ok(()) + } else { + Err(Box::new(request)) + } + } + + pub(super) fn take_thread_event_snapshot( + &mut self, + thread_id: &str, + session_refreshed: bool, + ) -> ThreadEventSnapshot { + let channel = self.ensure_thread_channel(thread_id); + if session_refreshed { + channel.store.rebase_buffer_after_session_refresh(); + } + channel.store.drain_snapshot() + } + + pub(crate) fn apply_notification(&mut self, notification: ServerNotification) { + self.track_agents_overview_notification(¬ification); + self.observe_notification(¬ification); + let target = server_notification_thread_target(¬ification); + if matches!( + target, + ServerNotificationThreadTarget::Thread(ref thread_id) + if self.thread_id.as_deref() != Some(thread_id.as_str()) + ) || matches!(target, ServerNotificationThreadTarget::InvalidThreadId(_)) + { + if let ServerNotificationThreadTarget::Thread(thread_id) = target { + self.ensure_thread_channel(&thread_id) + .store + .push_notification_ref(¬ification); + } + return; + } + let previous_turn_id = self.projection.active_turn_id().map(str::to_owned); + self.projection.apply(notification); + let active_turn_id = self.projection.active_turn_id(); + if active_turn_id != previous_turn_id.as_deref() { + self.active_turn_started_at = active_turn_id.map(|_| Instant::now()); + } + } + + pub(super) fn note_outbound_response( + &mut self, + response: &crate::bottom_pane::AppServerResponse, + ) { + let request_id = match response { + crate::bottom_pane::AppServerResponse::Command { id, .. } + | crate::bottom_pane::AppServerResponse::FileChange { id, .. } + | crate::bottom_pane::AppServerResponse::Permissions { id, .. } + | crate::bottom_pane::AppServerResponse::UserInput { id, .. } => id, + }; + for channel in self.thread_event_channels.values_mut() { + channel.store.note_outbound_response(request_id); + } + } + + fn observe_notification(&mut self, notification: &ServerNotification) { + match notification { + ServerNotification::ThreadStarted(params) => { + self.agent_navigation.upsert( + params.thread.id.clone(), + params.thread.agent_nickname.clone(), + params.thread.agent_role.clone(), + false, + ); + if params.thread.parent_thread_id.is_some() { + self.agent_navigation + .mark_parent_owned(params.thread.id.clone()); + } + } + ServerNotification::ThreadClosed(params) => { + self.agent_navigation.mark_closed(¶ms.thread_id); + } + ServerNotification::ThreadStatusChanged(params) => match ¶ms.status { + app_server_protocol::protocol::v2::ThreadStatus::Active { .. } => { + self.agent_navigation.mark_running(¶ms.thread_id) + } + app_server_protocol::protocol::v2::ThreadStatus::Idle + | app_server_protocol::protocol::v2::ThreadStatus::SystemError => { + self.agent_navigation.mark_stopped(¶ms.thread_id) + } + app_server_protocol::protocol::v2::ThreadStatus::NotLoaded => {} + }, + ServerNotification::ItemStarted(params) => { + self.observe_item(¶ms.item); + } + ServerNotification::ItemCompleted(params) => { + self.observe_item(¶ms.item); + } + _ => {} + } + } + + fn observe_item(&mut self, item: &ThreadItem) { + if let Some(activity) = crate::multi_agents::sub_agent_activity_display(item) { + // Activity is emitted on the parent stream before a child thread can be resumed + // independently. Treat that observation as authoritative for direct-input policy. + self.agent_navigation + .mark_parent_owned(activity.thread_id.clone()); + self.agent_navigation.record_sub_agent_activity(activity); + return; + } + if let ThreadItem::CollabAgentToolCall { + tool: app_server_protocol::protocol::v2::CollabAgentTool::SpawnAgent, + receiver_thread_ids, + .. + } = item + { + for thread_id in receiver_thread_ids { + self.agent_navigation.mark_parent_owned(thread_id.clone()); + } + } + } +} diff --git a/lime-rs/crates/tui/src/app/thread_settings.rs b/lime-rs/crates/tui/src/app/thread_settings.rs new file mode 100644 index 000000000..da456c4f8 --- /dev/null +++ b/lime-rs/crates/tui/src/app/thread_settings.rs @@ -0,0 +1,111 @@ +//! Thread-scoped model, provider, permission and collaboration settings. +//! +//! Settings are mirrored from the App Server session into the active TUI projection. This module +//! owns local normalization and Codex-shaped collaboration mode selection; persistence remains in +//! `AppServerSession`. + +use super::*; +use crate::collaboration_modes; +use crate::settings::{cycle_setting, PERMISSION_PROFILES}; + +impl App { + pub(crate) fn set_settings( + &mut self, + model: Option, + model_provider: Option, + reasoning_effort: Option, + permissions: Option, + ) { + self.model = model; + self.model_provider = model_provider; + self.reasoning_effort = reasoning_effort; + self.permissions = permissions; + if let Some(collaboration_mode) = self.collaboration_mode.as_mut() { + if let Some(model) = self.model.as_deref() { + collaboration_mode.settings.model = model.to_string(); + } + if let Some(effort) = self.reasoning_effort.as_ref() { + collaboration_mode.settings.reasoning_effort = Some(effort.clone()); + } + } + self.sync_default_collaboration_mode(); + } + + pub(crate) fn set_permission_profiles(&mut self, profiles: impl IntoIterator) { + let mut next = Vec::new(); + for profile in profiles { + let profile = profile.trim(); + if profile.is_empty() || next.iter().any(|value| value == profile) { + continue; + } + next.push(profile.to_string()); + } + self.permission_profiles = next; + } + + pub(crate) fn cycle_permission_profile(&self, current: Option<&str>, direction: i8) -> String { + if self.permission_profiles.is_empty() { + return cycle_setting(&PERMISSION_PROFILES, current, direction); + } + cycle_setting(&self.permission_profiles, current, direction) + } + + pub(crate) fn open_model_picker( + &mut self, + models: Vec, + ) { + self.model_catalog = ModelCatalog::new(models) + .with_collaboration_modes(self.model_catalog.collaboration_modes.clone()); + let models = self.model_catalog.try_list_models().unwrap_or_default(); + self.model_picker = Some(ModelPicker::new(models)); + } + + pub(crate) fn set_collaboration_modes( + &mut self, + collaboration_modes: Vec, + ) { + self.model_catalog.collaboration_modes = collaboration_modes; + self.sync_default_collaboration_mode(); + } + + fn sync_default_collaboration_mode(&mut self) { + if self.collaboration_mode.is_some() { + return; + } + let mask = collaboration_modes::default_mode_mask(&self.model_catalog) + .or_else(|| collaboration_modes::default_mask(&self.model_catalog)); + self.collaboration_mode = mask.and_then(|mask| { + collaboration_modes::to_mode( + &mask, + self.model.as_deref(), + self.reasoning_effort.as_deref(), + ) + }); + } + + pub(crate) fn next_collaboration_mode(&self) -> Option { + let current_mask = self.collaboration_mode.as_ref().map(|mode| { + app_server_protocol::protocol::v2::CollaborationModeMask { + name: String::new(), + mode: Some(mode.mode), + model: Some(mode.settings.model.clone()), + reasoning_effort: Some(mode.settings.reasoning_effort.clone()), + } + }); + let mask = collaboration_modes::next_mask(&self.model_catalog, current_mask.as_ref())?; + collaboration_modes::to_mode( + &mask, + self.model.as_deref(), + self.reasoning_effort.as_deref(), + ) + } + + pub(crate) fn plan_mode(&self) -> Option { + let mask = collaboration_modes::plan_mask(&self.model_catalog)?; + collaboration_modes::to_mode( + &mask, + self.model.as_deref(), + self.reasoning_effort.as_deref(), + ) + } +} diff --git a/lime-rs/crates/tui/src/app_server_session.rs b/lime-rs/crates/tui/src/app_server_session.rs index 761447c29..e83521d4a 100644 --- a/lime-rs/crates/tui/src/app_server_session.rs +++ b/lime-rs/crates/tui/src/app_server_session.rs @@ -2,28 +2,31 @@ use std::collections::HashSet; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; -use anyhow::{Context, Result, anyhow, bail}; +use anyhow::{anyhow, bail, Context, Result}; use app_server_client::{ - ClientSession, RemoteTransportConfig, RequestHandle, SessionEvent, StdioTransportConfig, + AppServerEvent, ClientSession, RemoteTransportConfig, RequestHandle, StdioTransportConfig, }; use app_server_protocol::protocol::v2::{ - CurrentTimeReadResponse, METHOD_PERMISSION_PROFILE_LIST, METHOD_PROMPT_HISTORY_APPEND, - METHOD_PROMPT_HISTORY_READ, METHOD_THREAD_ARCHIVE, METHOD_THREAD_QUEUE_ADD, - METHOD_THREAD_QUEUE_DELETE, METHOD_THREAD_QUEUE_LIST, METHOD_THREAD_READ, METHOD_THREAD_RESUME, - METHOD_THREAD_SETTINGS_UPDATE, METHOD_THREAD_START, METHOD_TURN_INTERRUPT, METHOD_TURN_START, - METHOD_TURN_STEER, ModelListParams, ModelListResponse, PermissionProfileListParams, + CollaborationModeListParams, CollaborationModeListResponse, CollaborationModeMask, + CurrentTimeReadResponse, ModelListParams, ModelListResponse, PermissionProfileListParams, PermissionProfileListResponse, PromptHistoryAppendParams, PromptHistoryAppendResponse, PromptHistoryReadParams, PromptHistoryReadResponse, QueuedSubmission, ServerRequest, ThreadForkParams, ThreadForkResponse, ThreadListParams, ThreadListResponse, ThreadQueueAddParams, ThreadQueueAddResponse, ThreadQueueDeleteParams, ThreadQueueDeleteResponse, ThreadQueueListParams, ThreadQueueListResponse, ThreadReadParams, - ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, ThreadSettingsUpdateParams, - ThreadSettingsUpdateResponse, ThreadStartParams, ThreadStartResponse, ThreadUnarchiveParams, + ThreadReadResponse, ThreadResumeParams, ThreadResumeResponse, ThreadSetNameParams, + ThreadSetNameResponse, ThreadSettingsUpdateParams, ThreadSettingsUpdateResponse, + ThreadStartParams, ThreadStartResponse, ThreadStartSource, ThreadUnarchiveParams, ThreadUnarchiveResponse, TurnInterruptParams, TurnInterruptResponse, TurnStartParams, TurnStartResponse, TurnSteerParams, TurnSteerResponse, UserInput, + METHOD_COLLABORATION_MODE_LIST, METHOD_PERMISSION_PROFILE_LIST, METHOD_PROMPT_HISTORY_APPEND, + METHOD_PROMPT_HISTORY_READ, METHOD_THREAD_ARCHIVE, METHOD_THREAD_QUEUE_ADD, + METHOD_THREAD_QUEUE_DELETE, METHOD_THREAD_QUEUE_LIST, METHOD_THREAD_READ, METHOD_THREAD_RESUME, + METHOD_THREAD_SETTINGS_UPDATE, METHOD_THREAD_START, METHOD_TURN_INTERRUPT, METHOD_TURN_START, + METHOD_TURN_STEER, }; use app_server_protocol::{ - ClientCapabilities, ClientInfo, InitializeParams, JsonRpcError, JsonRpcRequest, RequestId, + ClientCapabilities, ClientInfo, InitializeParams, JsonRpcError, RequestId, }; use serde_json::Value; @@ -87,21 +90,10 @@ impl AppServerSession { cwd: PathBuf, model: Option, model_provider: Option, - ) -> Result { - let cwd = cwd.to_string_lossy().into_owned(); - let params = ThreadStartParams { - cwd: Some(cwd.clone()), - runtime_workspace_roots: Some(vec![cwd]), - model, - model_provider, - experimental_raw_events: false, - ..ThreadStartParams::default() - }; - let response: ThreadStartResponse = self - .request_handle - .request(METHOD_THREAD_START, params) - .await - .context("failed to start App Server thread")?; + ) -> Result { + let response = self + .start_thread_with_session_start_source(cwd, model, model_provider, None) + .await?; let thread_id = response.thread.id.clone(); self.session_id = Some(response.thread.session_id.clone()); self.thread_id = Some(thread_id.clone()); @@ -109,7 +101,33 @@ impl AppServerSession { .active_permission_profile .as_ref() .and_then(permission_profile_id); - Ok(thread_id) + Ok(response) + } + + /// Start a thread without changing the interactive session target. + pub(crate) async fn start_thread_with_session_start_source( + &self, + cwd: PathBuf, + model: Option, + model_provider: Option, + session_start_source: Option, + ) -> Result { + let cwd = cwd.to_string_lossy().into_owned(); + self.request_handle + .request( + METHOD_THREAD_START, + ThreadStartParams { + cwd: Some(cwd.clone()), + runtime_workspace_roots: Some(vec![cwd]), + model, + model_provider, + session_start_source, + experimental_raw_events: false, + ..ThreadStartParams::default() + }, + ) + .await + .context("failed to start App Server thread") } pub(crate) async fn resume_thread( @@ -176,10 +194,7 @@ impl AppServerSession { } #[allow(dead_code)] - pub(crate) async fn list_thread_page( - &self, - params: ThreadListParams, - ) -> Result { + pub(crate) async fn thread_list(&self, params: ThreadListParams) -> Result { self.request_handle .request( app_server_protocol::protocol::v2::METHOD_THREAD_LIST, @@ -200,7 +215,7 @@ impl AppServerSession { } #[allow(dead_code)] - pub(crate) async fn read_thread( + pub(crate) async fn thread_read( &self, thread_id: impl Into, include_turns: bool, @@ -218,7 +233,7 @@ impl AppServerSession { } #[allow(dead_code)] - pub(crate) async fn archive_thread(&self, thread_id: impl Into) -> Result<()> { + pub(crate) async fn thread_archive(&self, thread_id: impl Into) -> Result<()> { self.request_handle .request( METHOD_THREAD_ARCHIVE, @@ -231,8 +246,26 @@ impl AppServerSession { .context("failed to archive App Server thread") } + pub(crate) async fn thread_set_name( + &self, + thread_id: impl Into, + name: String, + ) -> Result<()> { + self.request_handle + .request( + app_server_protocol::protocol::v2::METHOD_THREAD_NAME_SET, + ThreadSetNameParams { + thread_id: thread_id.into(), + name, + }, + ) + .await + .map(|_: ThreadSetNameResponse| ()) + .context("failed to set App Server thread name") + } + #[allow(dead_code)] - pub(crate) async fn unarchive_thread( + pub(crate) async fn thread_unarchive( &self, thread_id: impl Into, ) -> Result { @@ -303,6 +336,42 @@ impl AppServerSession { bail!("model list pagination exceeded 16 pages") } + /// Discover optional collaboration modes from the App Server catalog. + /// + /// A server may legitimately omit this experimental method. Callers treat + /// an error as an empty catalog and keep the ordinary model picker usable. + pub(crate) async fn list_collaboration_modes(&self) -> Result> { + let response: CollaborationModeListResponse = self + .request_handle + .request( + METHOD_COLLABORATION_MODE_LIST, + CollaborationModeListParams {}, + ) + .await + .context("failed to list App Server collaboration modes")?; + Ok(response.data) + } + + pub(crate) async fn update_collaboration_mode( + &self, + collaboration_mode: agent_protocol::CollaborationMode, + ) -> Result<()> { + let thread_id = self.thread_id()?.to_string(); + let _: ThreadSettingsUpdateResponse = self + .request_handle + .request( + METHOD_THREAD_SETTINGS_UPDATE, + ThreadSettingsUpdateParams { + thread_id, + collaboration_mode: Some(collaboration_mode), + ..ThreadSettingsUpdateParams::default() + }, + ) + .await + .context("failed to update App Server collaboration mode")?; + Ok(()) + } + pub(crate) async fn list_queued_submissions( &self, limit: u32, @@ -433,6 +502,26 @@ impl AppServerSession { Ok(response.turn.id) } + pub(crate) async fn turn_start( + &self, + thread_id: impl Into, + input: Vec, + ) -> Result { + let response: TurnStartResponse = self + .request_handle + .request( + METHOD_TURN_START, + TurnStartParams { + thread_id: thread_id.into(), + input, + ..TurnStartParams::default() + }, + ) + .await + .context("failed to start background turn")?; + Ok(response.turn.id) + } + pub(crate) async fn interrupt(&self, turn_id: &str) -> Result<()> { let thread_id = self.thread_id()?.to_string(); let _: TurnInterruptResponse = self @@ -449,6 +538,25 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn turn_interrupt( + &self, + thread_id: impl Into, + turn_id: impl Into, + ) -> Result<()> { + let _: TurnInterruptResponse = self + .request_handle + .request( + METHOD_TURN_INTERRUPT, + TurnInterruptParams { + thread_id: thread_id.into(), + turn_id: turn_id.into(), + }, + ) + .await + .context("failed to interrupt background turn")?; + Ok(()) + } + pub(crate) async fn steer_turn_input( &self, turn_id: &str, @@ -507,7 +615,7 @@ impl AppServerSession { Ok(response.deleted) } - pub(crate) async fn next_event(&mut self) -> Option { + pub(crate) async fn next_event(&mut self) -> Option { self.session.next_event().await } @@ -556,20 +664,6 @@ impl AppServerSession { .context("failed to reject unsupported server request") } - pub(crate) async fn reject_raw_server_request(&self, request: JsonRpcRequest) -> Result<()> { - let method = request.method.clone(); - self.request_handle - .reject( - request.id, - JsonRpcError::new( - app_server_protocol::error_codes::METHOD_NOT_FOUND, - format!("TUI client does not support server request {method}"), - ), - ) - .await - .context("failed to reject unknown server request") - } - pub(crate) async fn shutdown(self) -> Result<()> { self.session .shutdown() diff --git a/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs b/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs index 9c48264ba..ff92d2c50 100644 --- a/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs +++ b/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs @@ -1,4 +1,3 @@ -use app_server_protocol::RequestId; use app_server_protocol::protocol::v2::{ CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, FileChangeApprovalDecision, @@ -6,6 +5,7 @@ use app_server_protocol::protocol::v2::{ PermissionGrantScope, PermissionsRequestApprovalParams, PermissionsRequestApprovalResponse, ServerRequest, }; +use app_server_protocol::RequestId; use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; use super::AppServerResponse; diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs index 346012242..7530358c4 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs @@ -1,6 +1,17 @@ -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::cell::RefMut; use unicode_segmentation::UnicodeSegmentation; +mod attachment_state; +mod draft_state; +mod history_search; +mod reconnect; + +use self::attachment_state::AttachmentState; +use self::draft_state::DraftState; +use self::history_search::HistorySearchState; +use crate::bottom_pane::textarea::{TextArea, TextAreaState}; + const MAX_HISTORY_ENTRIES: usize = 200; #[derive(Debug, Clone, PartialEq, Eq)] @@ -18,34 +29,71 @@ pub(crate) enum InputResult { Quit, } -#[derive(Debug, Default)] -struct HistorySearchState { - query: String, - draft: String, - selected_index: Option, -} - #[derive(Debug, Default)] pub(crate) struct ChatComposer { - text: String, - cursor: usize, + draft: DraftState, + attachments: AttachmentState, history: Vec, history_index: Option, - saved_draft: Option, history_search: Option, } impl ChatComposer { + pub(crate) fn textarea(&self) -> &TextArea { + &self.draft.textarea + } + + pub(crate) fn textarea_state_mut(&self) -> RefMut<'_, TextAreaState> { + self.draft.textarea_state_mut() + } + pub(crate) fn text(&self) -> &str { - &self.text + self.draft.textarea.text() } pub(crate) fn cursor(&self) -> usize { - self.cursor + self.draft.textarea.cursor() + } + + pub(crate) fn desired_height(&self, width: u16) -> u16 { + self.draft.textarea.desired_height(width) + } + + #[allow(dead_code)] + pub(crate) fn cursor_position(&self, width: u16) -> Option<(usize, usize)> { + self.draft.textarea.cursor_position(width) } pub(crate) fn is_empty(&self) -> bool { - self.text.is_empty() + self.draft.textarea.is_empty() + } + + pub(crate) fn pending_image_count(&self) -> usize { + self.attachments.len() + } + + pub(crate) fn pending_images(&self) -> &[std::path::PathBuf] { + self.attachments.paths() + } + + pub(crate) fn has_pending_images(&self) -> bool { + !self.attachments.is_empty() + } + + pub(crate) fn attach_image(&mut self, path: std::path::PathBuf) { + self.attachments.attach(path); + } + + pub(crate) fn take_pending_images(&mut self) -> Vec { + self.attachments.take() + } + + pub(crate) fn restore_pending_images(&mut self, images: Vec) { + self.attachments.restore(images); + } + + pub(crate) fn remove_last_pending_image(&mut self) -> bool { + self.attachments.remove_last() } pub(crate) fn history_search_active(&self) -> bool { @@ -77,21 +125,36 @@ impl ChatComposer { self.history.drain(..keep_from); } self.history_index = None; - self.saved_draft = None; + self.draft.saved_draft = None; self.history_search = None; } pub(crate) fn insert(&mut self, value: &str) { - self.text.insert_str(self.cursor, value); - self.cursor += value.len(); + self.draft.textarea.insert(value); self.reset_history_navigation(); } pub(crate) fn handle_key_event(&mut self, key: KeyEvent) -> InputResult { + if matches!(key.kind, KeyEventKind::Release) { + return InputResult::None; + } if self.history_search.is_some() { return self.handle_history_search_key(key); } + let editor_key = matches!(key.code, KeyCode::Char('b' | 'f' | 'w' | 'k' | 'u' | 'y')) + && key.modifiers == KeyModifiers::CONTROL + || matches!(key.code, KeyCode::Char('d')) && key.modifiers == KeyModifiers::ALT + || matches!(key.code, KeyCode::Backspace | KeyCode::Delete) + && key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT); + if editor_key { + self.draft.textarea.input(key); + self.reset_history_navigation(); + return InputResult::Changed; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { return match key.code { KeyCode::Char('c') => InputResult::Interrupt, @@ -101,11 +164,11 @@ impl ChatComposer { InputResult::Changed } KeyCode::Char('a') => { - self.cursor = self.line_start(); + self.draft.textarea.move_line_start(); InputResult::Changed } KeyCode::Char('e') => { - self.cursor = self.line_end(); + self.draft.textarea.move_line_end(); InputResult::Changed } KeyCode::Char('g') => InputResult::OpenExternalEditor, @@ -133,42 +196,25 @@ impl ChatComposer { InputResult::Changed } KeyCode::Enter => self.submit(), - KeyCode::Char(ch) => { - self.insert(&ch.to_string()); - InputResult::Changed - } - KeyCode::Backspace => { - if self.remove_previous_grapheme() { + KeyCode::Up if !self.draft.textarea.text().contains('\n') => self.history_previous(), + KeyCode::Down if !self.draft.textarea.text().contains('\n') => self.history_next(), + KeyCode::Char(_) + | KeyCode::Backspace + | KeyCode::Delete + | KeyCode::Left + | KeyCode::Right + | KeyCode::Home + | KeyCode::End => { + let before = (self.draft.textarea.text().to_string(), self.cursor()); + self.draft.textarea.input(key); + let changed = before.0 != self.draft.textarea.text() || before.1 != self.cursor(); + if changed { + self.reset_history_navigation(); InputResult::Changed } else { InputResult::None } } - KeyCode::Delete => { - if self.remove_next_grapheme() { - InputResult::Changed - } else { - InputResult::None - } - } - KeyCode::Left => { - self.cursor = self.previous_grapheme_start(); - InputResult::Changed - } - KeyCode::Right => { - self.cursor = self.next_grapheme_end(); - InputResult::Changed - } - KeyCode::Home => { - self.cursor = self.line_start(); - InputResult::Changed - } - KeyCode::End => { - self.cursor = self.line_end(); - InputResult::Changed - } - KeyCode::Up if !self.text.contains('\n') => self.history_previous(), - KeyCode::Down if !self.text.contains('\n') => self.history_next(), _ => InputResult::None, } } @@ -185,27 +231,26 @@ impl ChatComposer { where F: FnOnce(String) -> InputResult, { - if self.text.trim().is_empty() { + if self.draft.textarea.text().trim().is_empty() { return InputResult::None; } - let submitted = std::mem::take(&mut self.text); - self.cursor = 0; + let submitted = self.draft.textarea.take(); self.history.push(submitted.clone()); if self.history.len() > MAX_HISTORY_ENTRIES { self.history.remove(0); } self.history_index = None; - self.saved_draft = None; + self.draft.saved_draft = None; self.history_search = None; action(submitted) } fn start_history_search(&mut self) -> InputResult { self.history_index = None; - self.saved_draft = None; + self.draft.saved_draft = None; self.history_search = Some(HistorySearchState { query: String::new(), - draft: self.text.clone(), + draft: self.draft.textarea.text().to_string(), selected_index: None, }); InputResult::Changed @@ -302,24 +347,7 @@ impl ChatComposer { return; }; let selected_index = search.selected_index; - let query = search.query.to_lowercase(); - if query.is_empty() { - return; - } - let start = selected_index - .map(|index| { - if older { - index.saturating_sub(1) - } else { - index - } - }) - .unwrap_or_else(|| self.history.len().saturating_sub(1)); - let found = (0..=start).rev().find(|index| { - self.history - .get(*index) - .is_some_and(|entry| entry.to_lowercase().contains(&query)) - }); + let found = history_search::find_match(&self.history, &search.query, selected_index, older); if let Some(index) = found { if let Some(search) = self.history_search.as_mut() { search.selected_index = Some(index); @@ -339,18 +367,7 @@ impl ChatComposer { let Some(search) = self.history_search.as_ref() else { return; }; - let Some(current) = search.selected_index else { - return; - }; - let query = search.query.to_lowercase(); - if query.is_empty() { - return; - } - let found = ((current + 1)..self.history.len()).find(|index| { - self.history - .get(*index) - .is_some_and(|entry| entry.to_lowercase().contains(&query)) - }); + let found = history_search::find_newer(&self.history, &search.query, search.selected_index); if let Some(index) = found { if let Some(search) = self.history_search.as_mut() { search.selected_index = Some(index); @@ -359,63 +376,12 @@ impl ChatComposer { } } - fn previous_grapheme_start(&self) -> usize { - self.text[..self.cursor] - .grapheme_indices(true) - .next_back() - .map(|(index, _)| index) - .unwrap_or(0) - } - - fn next_grapheme_end(&self) -> usize { - self.text[self.cursor..] - .graphemes(true) - .next() - .map(|grapheme| self.cursor + grapheme.len()) - .unwrap_or(self.text.len()) - } - - fn remove_previous_grapheme(&mut self) -> bool { - if self.cursor == 0 { - return false; - } - let start = self.previous_grapheme_start(); - self.text.replace_range(start..self.cursor, ""); - self.cursor = start; - self.reset_history_navigation(); - true - } - - fn remove_next_grapheme(&mut self) -> bool { - if self.cursor == self.text.len() { - return false; - } - let end = self.next_grapheme_end(); - self.text.replace_range(self.cursor..end, ""); - self.reset_history_navigation(); - true - } - - fn line_start(&self) -> usize { - self.text[..self.cursor] - .rfind('\n') - .map(|index| index + 1) - .unwrap_or(0) - } - - fn line_end(&self) -> usize { - self.text[self.cursor..] - .find('\n') - .map(|index| self.cursor + index) - .unwrap_or(self.text.len()) - } - fn history_previous(&mut self) -> InputResult { if self.history.is_empty() { return InputResult::None; } if self.history_index.is_none() { - self.saved_draft = Some(self.text.clone()); + self.draft.saved_draft = Some(self.draft.textarea.text().to_string()); } let index = self .history_index @@ -436,20 +402,19 @@ impl ChatComposer { self.replace_text(self.history[next].clone()); } else { self.history_index = None; - let draft = self.saved_draft.take().unwrap_or_default(); + let draft = self.draft.saved_draft.take().unwrap_or_default(); self.replace_text(draft); } InputResult::Changed } fn replace_text(&mut self, text: String) { - self.text = text; - self.cursor = self.text.len(); + self.draft.textarea.replace(text); } fn reset_history_navigation(&mut self) { self.history_index = None; - self.saved_draft = None; + self.draft.saved_draft = None; self.history_search = None; } } @@ -514,6 +479,38 @@ mod tests { ); } + #[test] + fn editor_word_and_kill_shortcuts_share_textarea_semantics() { + let mut composer = ChatComposer::default(); + composer.insert("alpha beta"); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL)); + assert_eq!(composer.text(), "alpha "); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(composer.text(), "alpha beta"); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(composer.text(), "alpha bet"); + } + + #[test] + fn key_release_does_not_insert_or_submit() { + let mut composer = ChatComposer::default(); + let mut release = key(KeyCode::Char('x')); + release.kind = KeyEventKind::Release; + + assert_eq!(composer.handle_key_event(release), InputResult::None); + assert!(composer.is_empty()); + + composer.insert("draft"); + let mut submit_release = key(KeyCode::Enter); + submit_release.kind = KeyEventKind::Release; + assert_eq!(composer.handle_key_event(submit_release), InputResult::None); + assert_eq!(composer.text(), "draft"); + } + #[test] fn tab_queues_non_empty_draft_and_clears_composer() { let mut composer = ChatComposer::default(); diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/attachment_state.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/attachment_state.rs new file mode 100644 index 000000000..4c9c793ea --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/attachment_state.rs @@ -0,0 +1,66 @@ +//! Local image attachment bookkeeping for the chat composer. +//! +//! The attachment state is intentionally owned by `ChatComposer`, matching Codex's ownership +//! boundary. Runtime lowering still happens in the App Server session and keeps image parts before +//! text parts in the canonical `UserInput` vector. + +use std::path::PathBuf; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct AttachmentState { + local_images: Vec, +} + +impl AttachmentState { + pub(super) fn is_empty(&self) -> bool { + self.local_images.is_empty() + } + + pub(super) fn len(&self) -> usize { + self.local_images.len() + } + + pub(super) fn attach(&mut self, path: PathBuf) { + self.local_images.push(path); + } + + pub(super) fn take(&mut self) -> Vec { + std::mem::take(&mut self.local_images) + } + + pub(super) fn restore(&mut self, images: Vec) { + self.local_images = images; + } + + pub(super) fn paths(&self) -> &[PathBuf] { + &self.local_images + } + + pub(super) fn remove_last(&mut self) -> bool { + self.local_images.pop().is_some() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::AttachmentState; + + #[test] + fn attachments_are_ordered_and_restorable() { + let mut state = AttachmentState::default(); + state.attach(PathBuf::from("one.png")); + state.attach(PathBuf::from("two.png")); + assert_eq!( + state.paths(), + &[PathBuf::from("one.png"), PathBuf::from("two.png")] + ); + let taken = state.take(); + assert!(state.is_empty()); + state.restore(taken); + assert_eq!(state.len(), 2); + assert!(state.remove_last()); + assert_eq!(state.paths(), &[PathBuf::from("one.png")]); + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/draft_state.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/draft_state.rs new file mode 100644 index 000000000..a1578bb04 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/draft_state.rs @@ -0,0 +1,18 @@ +//! Editable draft state kept separate from composer control flow. + +use std::cell::{RefCell, RefMut}; + +use crate::bottom_pane::textarea::{TextArea, TextAreaState}; + +#[derive(Debug, Default)] +pub(super) struct DraftState { + pub(super) textarea: TextArea, + pub(super) textarea_state: RefCell, + pub(super) saved_draft: Option, +} + +impl DraftState { + pub(super) fn textarea_state_mut(&self) -> RefMut<'_, TextAreaState> { + self.textarea_state.borrow_mut() + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/history_search.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/history_search.rs new file mode 100644 index 000000000..8a102d6a5 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/history_search.rs @@ -0,0 +1,103 @@ +//! History-search state and matching rules for `ChatComposer`. + +#[derive(Debug, Default)] +pub(super) struct HistorySearchState { + pub(super) query: String, + pub(super) draft: String, + pub(super) selected_index: Option, +} + +pub(super) fn find_older( + history: &[String], + query: &str, + selected_index: Option, +) -> Option { + let query = query.to_lowercase(); + if query.is_empty() || history.is_empty() { + return None; + } + let start = selected_index + .map(|index| index.saturating_sub(1)) + .unwrap_or(history.len() - 1); + (0..=start).rev().find(|index| { + history + .get(*index) + .is_some_and(|entry| entry.to_lowercase().contains(&query)) + }) +} + +pub(super) fn find_match( + history: &[String], + query: &str, + selected_index: Option, + older: bool, +) -> Option { + if older { + return find_older(history, query, selected_index); + } + let query = query.to_lowercase(); + if query.is_empty() || history.is_empty() { + return None; + } + let start = selected_index + .map(|index| { + if older { + index.saturating_sub(1) + } else { + index + } + }) + .unwrap_or(history.len() - 1); + (0..=start).rev().find(|index| { + history + .get(*index) + .is_some_and(|entry| entry.to_lowercase().contains(&query)) + }) +} + +pub(super) fn find_newer( + history: &[String], + query: &str, + selected_index: Option, +) -> Option { + let selected_index = selected_index?; + let query = query.to_lowercase(); + if query.is_empty() { + return None; + } + ((selected_index + 1)..history.len()).find(|index| { + history + .get(*index) + .is_some_and(|entry| entry.to_lowercase().contains(&query)) + }) +} + +#[cfg(test)] +mod tests { + use super::{find_match, find_newer, find_older}; + + fn history() -> Vec { + vec![ + "git status".to_string(), + "cargo test".to_string(), + "git diff".to_string(), + ] + } + + #[test] + fn older_and_newer_matches_are_case_insensitive_and_bounded() { + let history = history(); + assert_eq!(find_older(&history, "GIT", None), Some(2)); + assert_eq!(find_older(&history, "GIT", Some(2)), Some(0)); + assert_eq!(find_match(&history, "git", Some(2), false), Some(2)); + assert_eq!(find_newer(&history, "git", Some(0)), Some(2)); + assert_eq!(find_newer(&history, "git", Some(2)), None); + } + + #[test] + fn empty_queries_and_missing_selection_do_not_match() { + let history = history(); + assert_eq!(find_older(&history, "", None), None); + assert_eq!(find_newer(&history, "git", None), None); + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect.rs new file mode 100644 index 000000000..83801534f --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect.rs @@ -0,0 +1,33 @@ +//! Offline composer editing used while the App Server connection is recovering. +//! +//! Enter and Tab deliberately leave the draft untouched. The runtime owns reconnect attempts; +//! this module only provides the Codex-shaped editing boundary for a composer that is offline. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind}; + +use super::ChatComposer; + +impl ChatComposer { + /// Apply a basic editor key while disconnected without submitting or queueing the draft. + #[allow(dead_code)] + pub(crate) fn handle_disconnected_key(&mut self, key: KeyEvent) { + if let Some(search) = self.history_search.take() { + self.replace_text(search.draft); + } + self.history_index = None; + self.draft.saved_draft = None; + + if matches!(key.code, KeyCode::Enter | KeyCode::Tab) + || !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + return; + } + + self.draft.textarea.input(key); + self.reset_history_navigation(); + } +} + +#[cfg(test)] +#[path = "reconnect_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect_tests.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect_tests.rs new file mode 100644 index 000000000..3376883da --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/reconnect_tests.rs @@ -0,0 +1,61 @@ +use super::super::ChatComposer; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn disconnected_edit_preserves_draft_and_cancels_history_preview() { + let mut composer = ChatComposer::default(); + composer.load_history(["history preview".to_string()]); + composer.insert("original draft"); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL)); + composer.handle_key_event(key(KeyCode::Char('h'))); + assert_eq!(composer.text(), "history preview"); + + composer.handle_disconnected_key(key(KeyCode::Char('!'))); + + assert_eq!(composer.text(), "original draft!"); + assert!(!composer.history_search_active()); +} + +#[test] +fn disconnected_enter_and_tab_leave_input_available_for_reconnect() { + let mut composer = ChatComposer::default(); + composer.insert("unsent"); + + composer.handle_disconnected_key(key(KeyCode::Enter)); + assert_eq!(composer.text(), "unsent"); + composer.handle_disconnected_key(key(KeyCode::Tab)); + assert_eq!(composer.text(), "unsent"); +} + +#[test] +fn disconnected_editing_preserves_graphemes_and_cursor_boundaries() { + let mut composer = ChatComposer::default(); + composer.insert("你👍好"); + + composer.handle_disconnected_key(key(KeyCode::Backspace)); + assert_eq!(composer.text(), "你👍"); + composer.handle_disconnected_key(key(KeyCode::Backspace)); + assert_eq!(composer.text(), "你"); + + composer.handle_disconnected_key(key(KeyCode::Char('a'))); + composer.handle_disconnected_key(key(KeyCode::Left)); + composer.handle_disconnected_key(key(KeyCode::Char('界'))); + assert_eq!(composer.text(), "你界a"); +} + +#[test] +fn disconnected_repeat_key_is_editable_but_never_submits() { + let mut composer = ChatComposer::default(); + composer.insert("draft"); + let mut repeated = key(KeyCode::Char('!')); + repeated.kind = crossterm::event::KeyEventKind::Repeat; + + composer.handle_disconnected_key(repeated); + composer.handle_disconnected_key(key(KeyCode::Enter)); + + assert_eq!(composer.text(), "draft!"); +} diff --git a/lime-rs/crates/tui/src/bottom_pane/mod.rs b/lime-rs/crates/tui/src/bottom_pane/mod.rs index 5eb8dde25..9584c8b0e 100644 --- a/lime-rs/crates/tui/src/bottom_pane/mod.rs +++ b/lime-rs/crates/tui/src/bottom_pane/mod.rs @@ -2,16 +2,17 @@ mod approval_overlay; mod chat_composer; mod render; mod request_user_input; +mod textarea; use std::collections::VecDeque; -use app_server_protocol::RequestId; use app_server_protocol::protocol::v2::{ CommandExecutionApprovalDecision, CommandExecutionRequestApprovalResponse, FileChangeApprovalDecision, FileChangeRequestApprovalResponse, GrantedPermissionProfile, PermissionGrantScope, PermissionsRequestApprovalResponse, ServerRequest, ToolRequestUserInputResponse, }; +use app_server_protocol::RequestId; use crossterm::event::{Event, KeyEvent}; use approval_overlay::ApprovalOverlay; @@ -154,11 +155,13 @@ mod tests { use super::*; use app_server_protocol::protocol::v2::{ CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, - FileChangeApprovalDecision, FileChangeRequestApprovalParams, - PermissionsRequestApprovalParams, RequestPermissionProfile, ToolRequestUserInputOption, - ToolRequestUserInputParams, ToolRequestUserInputQuestion, + DynamicToolCallParams, DynamicToolCallPhase, FileChangeApprovalDecision, + FileChangeRequestApprovalParams, PermissionsRequestApprovalParams, + RequestPermissionProfile, ToolRequestUserInputOption, ToolRequestUserInputParams, + ToolRequestUserInputQuestion, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use serde_json::json; fn key(code: KeyCode) -> Event { Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) @@ -226,6 +229,47 @@ mod tests { assert!(!pane.is_active()); } + #[test] + fn unsupported_requests_return_for_rejection() { + let mut pane = BottomPane::default(); + let dynamic_tool = ServerRequest::DynamicToolCall { + id: RequestId::Integer(3), + params: DynamicToolCallParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + namespace: None, + tool: "browser.open".to_string(), + arguments: json!({}), + phase: DynamicToolCallPhase::Preflight, + approval_token: None, + }, + }; + assert!(matches!( + pane.enqueue(dynamic_tool), + Err(request) if matches!(*request, ServerRequest::DynamicToolCall { .. }) + )); + + let mcp_elicitation = serde_json::from_value::(json!({ + "method": "mcpServer/elicitation/request", + "id": 4, + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "serverName": "form-server", + "mode": "form", + "message": "Choose a value", + "requestedSchema": { "type": "object", "properties": {} } + } + })) + .expect("MCP elicitation request"); + assert!(matches!( + pane.enqueue(mcp_elicitation), + Err(request) if matches!(*request, ServerRequest::McpServerElicitationRequest { .. }) + )); + assert!(!pane.is_active()); + } + #[test] fn non_interactive_responses_fail_closed_for_every_supported_interaction() { let command = diff --git a/lime-rs/crates/tui/src/bottom_pane/render.rs b/lime-rs/crates/tui/src/bottom_pane/render.rs index ab379fd1c..e8e0f726b 100644 --- a/lime-rs/crates/tui/src/bottom_pane/render.rs +++ b/lime-rs/crates/tui/src/bottom_pane/render.rs @@ -1,8 +1,8 @@ -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; use super::approval_overlay::ApprovalRequest; use super::request_user_input::render as request_user_input_render; diff --git a/lime-rs/crates/tui/src/bottom_pane/request_user_input/mod.rs b/lime-rs/crates/tui/src/bottom_pane/request_user_input/mod.rs index db05e943e..1c415d135 100644 --- a/lime-rs/crates/tui/src/bottom_pane/request_user_input/mod.rs +++ b/lime-rs/crates/tui/src/bottom_pane/request_user_input/mod.rs @@ -1,9 +1,9 @@ use std::collections::BTreeMap; -use app_server_protocol::RequestId; use app_server_protocol::protocol::v2::{ ToolRequestUserInputAnswer, ToolRequestUserInputParams, ToolRequestUserInputResponse, }; +use app_server_protocol::RequestId; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use super::{AppServerResponse, ChatComposer, InputResult}; diff --git a/lime-rs/crates/tui/src/bottom_pane/request_user_input/render.rs b/lime-rs/crates/tui/src/bottom_pane/request_user_input/render.rs index 6487373a3..3007fe419 100644 --- a/lime-rs/crates/tui/src/bottom_pane/request_user_input/render.rs +++ b/lime-rs/crates/tui/src/bottom_pane/request_user_input/render.rs @@ -1,7 +1,7 @@ -use ratatui::Frame; use ratatui::layout::{Position, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use ratatui::Frame; use unicode_segmentation::UnicodeSegmentation; use crate::line_truncation::line_width; diff --git a/lime-rs/crates/tui/src/bottom_pane/textarea.rs b/lime-rs/crates/tui/src/bottom_pane/textarea.rs new file mode 100644 index 000000000..17a97c838 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/textarea.rs @@ -0,0 +1,617 @@ +//! Editable UTF-8 text buffer shared by the TUI composer and focused input overlays. +//! +//! `TextArea` is deliberately independent from submission policy. The parent composer owns +//! history, queueing, and attachments while this module owns cursor-safe editing primitives. + +use std::borrow::Cow; +use std::ops::Range; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::widgets::{StatefulWidgetRef, WidgetRef}; +use unicode_segmentation::UnicodeSegmentation; + +mod wrapping; + +const WORD_SEPARATORS: &str = "`~!@#$%^&*()-=+[{]}\\|;:'\",.<>/?"; + +fn is_word_separator(ch: char) -> bool { + WORD_SEPARATORS.contains(ch) +} + +fn split_word_pieces(run: &str) -> Vec<(usize, &str)> { + let mut pieces = Vec::new(); + for (segment_start, segment) in run.split_word_bound_indices() { + let mut piece_start = 0; + let mut chars = segment.char_indices(); + let Some((_, first_char)) = chars.next() else { + continue; + }; + let mut in_separator = is_word_separator(first_char); + for (idx, ch) in chars { + let is_separator = is_word_separator(ch); + if is_separator == in_separator { + continue; + } + pieces.push((segment_start + piece_start, &segment[piece_start..idx])); + piece_start = idx; + in_separator = is_separator; + } + pieces.push((segment_start + piece_start, &segment[piece_start..])); + } + pieces +} + +fn text_for_display(text: &str) -> Cow<'_, str> { + if text.contains('\t') { + Cow::Owned(text.replace('\t', " ")) + } else { + Cow::Borrowed(text) + } +} + +#[derive(Debug, Default)] +pub(crate) struct TextArea { + text: String, + cursor: usize, + kill_buffer: String, +} + +/// Viewport state kept outside the editable buffer, matching Codex's stateful textarea widget. +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct TextAreaState { + /// Index into wrapped lines of the first visible line. + scroll: u16, +} + +#[allow(dead_code)] +impl TextArea { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn text(&self) -> &str { + &self.text + } + + pub(crate) fn cursor(&self) -> usize { + self.cursor + } + + pub(crate) fn is_empty(&self) -> bool { + self.text.is_empty() + } + + pub(crate) fn replace(&mut self, text: String) { + self.text = text; + self.cursor = self.text.len(); + } + + pub(crate) fn set_text_clearing_elements(&mut self, text: &str) { + self.text = text.to_string(); + self.cursor = self.nearest_char_boundary(self.cursor.min(self.text.len())); + } + + pub(crate) fn take(&mut self) -> String { + self.cursor = 0; + std::mem::take(&mut self.text) + } + + pub(crate) fn insert(&mut self, value: &str) { + self.text.insert_str(self.cursor, value); + self.cursor += value.len(); + } + + pub(crate) fn insert_str(&mut self, value: &str) { + self.insert(value); + } + + pub(crate) fn insert_str_at(&mut self, pos: usize, value: &str) { + let pos = self.nearest_char_boundary(pos.min(self.text.len())); + self.text.insert_str(pos, value); + if pos <= self.cursor { + self.cursor += value.len(); + } + } + + pub(crate) fn replace_range(&mut self, range: Range, value: &str) { + let start = self.nearest_char_boundary(range.start.min(self.text.len())); + let end = self.nearest_char_boundary(range.end.min(self.text.len())); + if start > end { + return; + } + self.text.replace_range(start..end, value); + self.cursor = if self.cursor < start { + self.cursor + } else if self.cursor <= end { + start + value.len() + } else { + self.cursor.saturating_sub(end - start) + value.len() + } + .min(self.text.len()); + } + + pub(crate) fn set_cursor(&mut self, pos: usize) { + self.cursor = self.nearest_char_boundary(pos.min(self.text.len())); + } + + pub(crate) fn move_left(&mut self) { + self.cursor = self.previous_grapheme_start(); + } + + pub(crate) fn move_right(&mut self) { + self.cursor = self.next_grapheme_end(); + } + + pub(crate) fn move_line_start(&mut self) { + self.cursor = self.line_start(); + } + + pub(crate) fn move_line_end(&mut self) { + self.cursor = self.line_end(); + } + + pub(crate) fn remove_previous_grapheme(&mut self) -> bool { + if self.cursor == 0 { + return false; + } + let start = self.previous_grapheme_start(); + self.text.replace_range(start..self.cursor, ""); + self.cursor = start; + true + } + + pub(crate) fn remove_next_grapheme(&mut self) -> bool { + if self.cursor == self.text.len() { + return false; + } + let end = self.next_grapheme_end(); + self.text.replace_range(self.cursor..end, ""); + true + } + + /// Apply the default Codex editor key semantics without submission policy. + /// + /// `ChatComposer` owns submit/queue/shortcut dispatch. This method is the shared low-level + /// editor boundary used by focused overlays and the disconnected composer path. + pub(crate) fn input(&mut self, event: KeyEvent) { + if !matches!(event.kind, KeyEventKind::Press | KeyEventKind::Repeat) { + return; + } + + let control = event.modifiers.contains(KeyModifiers::CONTROL); + let alt = event.modifiers.contains(KeyModifiers::ALT); + match event.code { + KeyCode::Char('b') if control => self.move_left(), + KeyCode::Char('f') if control => self.move_right(), + KeyCode::Char('a') if control => self.move_line_start(), + KeyCode::Char('e') if control => self.move_line_end(), + KeyCode::Char('w') if control || alt => self.delete_backward_word(), + KeyCode::Char('d') if alt => self.delete_forward_word(), + KeyCode::Char('u') if control => self.kill_to_beginning_of_line(), + KeyCode::Char('k') if control => self.kill_to_end_of_line(), + KeyCode::Char('y') if control => self.yank(), + KeyCode::Backspace if control || alt => self.delete_backward_word(), + KeyCode::Delete if control || alt => self.delete_forward_word(), + KeyCode::Backspace => { + self.remove_previous_grapheme(); + } + KeyCode::Delete => { + self.remove_next_grapheme(); + } + KeyCode::Left => self.move_left(), + KeyCode::Right => self.move_right(), + KeyCode::Home => self.move_line_start(), + KeyCode::End => self.move_line_end(), + KeyCode::Enter if !control && !alt => self.insert_str("\n"), + KeyCode::Char(ch) + if !control + && !alt + && !ch.is_ascii_control() + && (event.modifiers.is_empty() || event.modifiers == KeyModifiers::SHIFT) => + { + self.insert_str(&ch.to_string()); + } + _ => {} + } + } + + pub(crate) fn delete_backward(&mut self, n: usize) { + for _ in 0..n { + if !self.remove_previous_grapheme() { + break; + } + } + } + + pub(crate) fn delete_forward(&mut self, n: usize) { + for _ in 0..n { + if !self.remove_next_grapheme() { + break; + } + } + } + + pub(crate) fn delete_forward_kill(&mut self, n: usize) { + if n == 0 || self.cursor >= self.text.len() { + return; + } + let mut target = self.cursor; + for _ in 0..n { + target = self.next_grapheme_end_at(target); + if target >= self.text.len() { + break; + } + } + self.kill_range(self.cursor..target); + } + + pub(crate) fn delete_backward_word(&mut self) { + let start = self.beginning_of_previous_word(); + self.kill_range(start..self.cursor); + } + + /// Delete text to the right of the cursor through the next word boundary. + pub(crate) fn delete_forward_word(&mut self) { + let end = self.end_of_next_word(); + if end > self.cursor { + self.kill_range(self.cursor..end); + } + } + + /// Kill from the cursor to the end of the current logical line. + pub(crate) fn kill_to_end_of_line(&mut self) { + let eol = self.line_end(); + let range = if self.cursor == eol { + (eol < self.text.len()).then_some(self.cursor..eol + 1) + } else { + Some(self.cursor..eol) + }; + if let Some(range) = range { + self.kill_range(range); + } + } + + /// Kill from the beginning of the current logical line through the cursor. + pub(crate) fn kill_to_beginning_of_line(&mut self) { + let bol = self.line_start(); + let range = if self.cursor == bol { + (bol > 0).then_some(bol - 1..bol) + } else { + Some(bol..self.cursor) + }; + if let Some(range) = range { + self.kill_range(range); + } + } + + /// Insert the most recently killed text at the cursor. + pub(crate) fn yank(&mut self) { + if !self.kill_buffer.is_empty() { + let text = self.kill_buffer.clone(); + self.insert_str(&text); + } + } + + pub(crate) fn beginning_of_previous_word(&self) -> usize { + let prefix = &self.text[..self.cursor]; + let Some((first_non_ws_idx, ch)) = prefix + .char_indices() + .rev() + .find(|&(_, ch)| !ch.is_whitespace()) + else { + return 0; + }; + let run_start = prefix[..first_non_ws_idx] + .char_indices() + .rev() + .find(|&(_, ch)| ch.is_whitespace()) + .map_or(0, |(idx, ch)| idx + ch.len_utf8()); + let run_end = first_non_ws_idx + ch.len_utf8(); + let pieces = split_word_pieces(&prefix[run_start..run_end]); + let mut pieces = pieces.into_iter().rev().peekable(); + let Some((piece_start, piece)) = pieces.next() else { + return run_start; + }; + let mut start = run_start + piece_start; + if piece.chars().all(is_word_separator) { + while let Some((idx, piece)) = pieces.peek() { + if !piece.chars().all(is_word_separator) { + break; + } + start = run_start + *idx; + pieces.next(); + } + } + start + } + + pub(crate) fn end_of_next_word(&self) -> usize { + let suffix = &self.text[self.cursor..]; + let Some(first_non_ws) = suffix.find(|ch: char| !ch.is_whitespace()) else { + return self.text.len(); + }; + let run = &suffix[first_non_ws..]; + let run = &run[..run.find(char::is_whitespace).unwrap_or(run.len())]; + let mut pieces = split_word_pieces(run).into_iter().peekable(); + let Some((start, piece)) = pieces.next() else { + return self.cursor + first_non_ws; + }; + let word_start = self.cursor + first_non_ws + start; + let mut end = word_start + piece.len(); + if piece.chars().all(is_word_separator) { + while let Some((idx, piece)) = pieces.peek() { + if !piece.chars().all(is_word_separator) { + break; + } + end = self.cursor + first_non_ws + *idx + piece.len(); + pieces.next(); + } + } + end + } + + fn kill_range(&mut self, range: Range) { + let start = self.nearest_char_boundary(range.start.min(self.text.len())); + let end = self.nearest_char_boundary(range.end.min(self.text.len())); + if start >= end { + return; + } + self.kill_buffer = self.text[start..end].to_string(); + self.replace_range(start..end, ""); + } + + pub(crate) fn wrapped_lines(&self, width: u16) -> Vec> { + wrapping::wrapped_lines(&self.text, width) + } + + pub(crate) fn cursor_position(&self, width: u16) -> Option<(usize, usize)> { + let lines = self.wrapped_lines(width); + wrapping::cursor_position(&self.text, &lines, width, self.cursor) + } + + pub(crate) fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.cursor_pos_with_state(area, TextAreaState::default()) + } + + pub(crate) fn cursor_pos_with_state( + &self, + area: Rect, + state: TextAreaState, + ) -> Option<(u16, u16)> { + if area.is_empty() { + return None; + } + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area, &lines, state.scroll); + let (row, column) = wrapping::cursor_position(&self.text, &lines, area.width, self.cursor)?; + Some(( + area.x.saturating_add(column as u16), + area.y + .saturating_add(row.saturating_sub(scroll as usize) as u16) + .min(area.bottom().saturating_sub(1)), + )) + } + + pub(crate) fn desired_height(&self, width: u16) -> u16 { + self.wrapped_lines(width).len().max(1) as u16 + } + + fn effective_scroll(&self, area: Rect, lines: &[Range], current: u16) -> u16 { + if area.height == 0 || lines.is_empty() { + return 0; + } + let total = lines.len() as u16; + if total <= area.height { + return 0; + } + let cursor_row = wrapping::cursor_position(&self.text, lines, area.width, self.cursor) + .map(|(row, _)| row as u16) + .unwrap_or_default(); + let max_scroll = total.saturating_sub(area.height); + let mut scroll = current.min(max_scroll); + if cursor_row < scroll { + scroll = cursor_row; + } else if cursor_row >= scroll.saturating_add(area.height) { + scroll = cursor_row + .saturating_add(1) + .saturating_sub(area.height) + .min(max_scroll); + } + scroll + } + + fn nearest_char_boundary(&self, mut pos: usize) -> usize { + while pos > 0 && !self.text.is_char_boundary(pos) { + pos -= 1; + } + pos + } + + fn render_lines(&self, area: Rect, buf: &mut Buffer, lines: &[Range], scroll: u16) { + let blank = " ".repeat(usize::from(area.width)); + for row in 0..area.height { + buf.set_string(area.x, area.y + row, &blank, Style::default()); + } + let start = usize::from(scroll); + let end = (start + usize::from(area.height)).min(lines.len()); + for (row, range) in lines[start..end].iter().enumerate() { + let content_end = range.end.saturating_sub(1).min(self.text.len()); + let content_start = range.start.min(content_end); + let visible = + wrapping::visible_prefix(&self.text[content_start..content_end], area.width); + buf.set_string( + area.x, + area.y + row as u16, + text_for_display(visible), + Style::default(), + ); + } + } + + fn previous_grapheme_start(&self) -> usize { + self.text[..self.cursor] + .grapheme_indices(true) + .next_back() + .map(|(index, _)| index) + .unwrap_or(0) + } + + fn next_grapheme_end(&self) -> usize { + self.next_grapheme_end_at(self.cursor) + } + + fn next_grapheme_end_at(&self, cursor: usize) -> usize { + self.text[cursor..] + .graphemes(true) + .next() + .map(|grapheme| cursor + grapheme.len()) + .unwrap_or(self.text.len()) + } + + fn line_start(&self) -> usize { + self.text[..self.cursor] + .rfind('\n') + .map(|index| index + 1) + .unwrap_or(0) + } + + fn line_end(&self) -> usize { + self.text[self.cursor..] + .find('\n') + .map(|index| self.cursor + index) + .unwrap_or(self.text.len()) + } +} + +impl WidgetRef for &TextArea { + fn render_ref(&self, area: Rect, buf: &mut Buffer) { + let lines = self.wrapped_lines(area.width); + self.render_lines(area, buf, &lines, 0); + } +} + +impl StatefulWidgetRef for &TextArea { + type State = TextAreaState; + + fn render_ref(&self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { + let lines = self.wrapped_lines(area.width); + state.scroll = self.effective_scroll(area, &lines, state.scroll); + self.render_lines(area, buf, &lines, state.scroll); + } +} + +#[cfg(test)] +mod tests { + use super::{TextArea, TextAreaState}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::layout::Rect; + use ratatui::widgets::StatefulWidgetRef; + + #[test] + fn unicode_cursor_and_grapheme_deletion_match_codex_textarea_semantics() { + let mut textarea = TextArea::default(); + textarea.insert("a👩🏽‍💻界"); + textarea.move_left(); + assert!(textarea.remove_previous_grapheme()); + assert_eq!(textarea.text(), "a界"); + assert_eq!(textarea.cursor(), 1); + } + + #[test] + fn line_navigation_stays_inside_the_current_logical_line() { + let mut textarea = TextArea::default(); + textarea.insert("first\nsecond"); + textarea.move_line_start(); + assert_eq!(textarea.cursor(), 6); + textarea.move_line_end(); + assert_eq!(textarea.cursor(), 12); + } + + #[test] + fn codex_cursor_pos_api_keeps_cursor_visible_in_a_scrolled_viewport() { + let mut textarea = TextArea::new(); + textarea.insert_str("abcdefghij"); + textarea.set_cursor(textarea.text().len()); + + let area = Rect::new(2, 3, 4, 2); + let state = TextAreaState::default(); + let (x, y) = textarea + .cursor_pos_with_state(area, state) + .expect("cursor position"); + + assert_eq!((x, y), (area.x + 2, area.y + 1)); + } + + #[test] + fn codex_buffer_replacement_preserves_cursor_only_at_valid_boundaries() { + let mut textarea = TextArea::new(); + textarea.insert_str("ab"); + textarea.set_cursor(1); + textarea.set_text_clearing_elements("cd"); + + assert_eq!(textarea.text(), "cd"); + assert_eq!(textarea.cursor(), 1); + } + + #[test] + fn stateful_render_clears_rows_outside_the_current_draft() { + let area = Rect::new(0, 0, 6, 2); + let mut textarea = TextArea::new(); + textarea.insert_str("longer draft"); + let mut buffer = ratatui::buffer::Buffer::empty(area); + let mut state = TextAreaState::default(); + StatefulWidgetRef::render_ref(&&textarea, area, &mut buffer, &mut state); + + textarea.set_text_clearing_elements("ok"); + StatefulWidgetRef::render_ref(&&textarea, area, &mut buffer, &mut state); + + assert_eq!(buffer[(0, 0)].symbol(), "o"); + assert_eq!(buffer[(2, 0)].symbol(), " "); + assert_eq!(buffer[(0, 1)].symbol(), " "); + } + + #[test] + fn codex_word_delete_and_line_kill_preserve_unicode_boundaries() { + let mut textarea = TextArea::new(); + textarea.insert_str("alpha 你👍 beta"); + textarea.set_cursor(textarea.text().len()); + textarea.delete_backward_word(); + assert_eq!(textarea.text(), "alpha 你👍 "); + textarea.yank(); + assert_eq!(textarea.text(), "alpha 你👍 beta"); + + textarea.set_cursor(9); + textarea.kill_to_beginning_of_line(); + assert_eq!(textarea.text(), "👍 beta"); + textarea.yank(); + assert_eq!(textarea.text(), "alpha 你👍 beta"); + } + + #[test] + fn codex_input_dispatches_emacs_style_editor_bindings() { + let mut textarea = TextArea::new(); + textarea.input(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); + textarea.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE)); + textarea.input(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + textarea.input(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)); + textarea.input(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(textarea.text(), "ab"); + textarea.input(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL)); + assert_eq!(textarea.text(), "abc"); + } + + #[test] + fn codex_word_boundaries_handle_cjk_and_separator_runs() { + let mut textarea = TextArea::new(); + textarea.insert_str("你好::world"); + textarea.set_cursor(textarea.text().len()); + assert_eq!(textarea.beginning_of_previous_word(), 8); + textarea.delete_backward_word(); + assert_eq!(textarea.text(), "你好::"); + textarea.set_cursor(0); + assert_eq!(textarea.end_of_next_word(), 3); + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping.rs b/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping.rs new file mode 100644 index 000000000..b6a87e8a6 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping.rs @@ -0,0 +1,201 @@ +//! Grapheme-safe composer wrapping that preserves textwrap's semantic breakpoints. +//! +//! Source ranges include a sentinel byte for cursor placement. Separators at soft breaks hang off +//! the preceding row without changing the source text. Full logical lines reserve an insertion row. + +use super::text_for_display; +use crate::width::display_width; +use std::ops::Range; +use textwrap::Options; +use unicode_segmentation::UnicodeSegmentation; + +struct WrappedWord { + range: Range, + width: usize, +} + +fn is_breakable_space(ch: char) -> bool { + ch.is_whitespace() && !matches!(ch, '\u{a0}' | '\u{2007}' | '\u{202f}') +} + +fn is_hangable_space(ch: char) -> bool { + is_breakable_space(ch) + && !matches!( + ch, + '\n' | '\r' | '\u{b}' | '\u{c}' | '\u{85}' | '\u{2028}' | '\u{2029}' + ) +} + +pub(super) fn visible_prefix(text: &str, width: u16) -> &str { + if width == 0 { + return ""; + } + if text.len() <= usize::from(width) { + return text; + } + text.grapheme_indices(true) + .filter(|(_, grapheme)| grapheme.chars().any(is_hangable_space)) + .nth(usize::from(width) - 1) + .map_or(text, |(start, grapheme)| &text[..start + grapheme.len()]) +} + +pub(super) fn wrapped_lines(text: &str, width: u16) -> Vec> { + let width = usize::from(width); + let options = Options::new(width).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit); + let wrapped_lines = crate::wrapping::wrap_ranges(text, &options); + if width == 0 || (wrapped_lines.len() == 1 && text.len() < width) { + return wrapped_lines; + } + + let mut breakpoints = vec![false; text.len() + 1]; + let mut words = Vec::new(); + let mut logical_start = 0; + for logical_line in text.split('\n') { + let mut word_start = logical_start; + for word in options.word_separator.find_words(logical_line) { + let word_end = word_start + word.word.len(); + for breakpoint in options.word_splitter.split_points(word.word) { + breakpoints[word_start + breakpoint] = true; + } + breakpoints[word_end] = true; + words.push(WrappedWord { + range: word_start..word_end, + width: display_width(word.word), + }); + word_start = word_end + word.whitespace.len(); + } + logical_start += logical_line.len() + 1; + } + + let mut lines = Vec::with_capacity(wrapped_lines.len()); + let mut line_start = 0; + let mut line_width = 0; + let mut line_has_text = false; + let mut line_active = false; + let mut logical_content_end = 0; + let mut processed_end = 0; + let mut previous_was_whitespace = false; + let mut word_index = 0; + let mut consumed_word_width = 0; + + for wrapped_line in wrapped_lines { + let line_end = wrapped_line.end.saturating_sub(1); + let fragment_start = wrapped_line.start.max(processed_end); + if fragment_start > line_end { + continue; + } + + while word_index < words.len() && words[word_index].range.end <= fragment_start { + word_index += 1; + consumed_word_width = 0; + } + + if !line_active { + line_start = fragment_start; + line_active = true; + let logical_line = text[fragment_start..] + .split('\n') + .next() + .unwrap_or_default(); + logical_content_end = + fragment_start + logical_line.trim_end_matches(is_hangable_space).len(); + } else if line_has_text && breakpoints[fragment_start] { + let remaining_word_width = words + .get(word_index) + .filter(|word| word.range.contains(&fragment_start)) + .map_or(0, |word| word.width.saturating_sub(consumed_word_width)); + if line_width + remaining_word_width > width { + lines.push(line_start..fragment_start + 1); + line_start = fragment_start; + line_width = 0; + line_has_text = false; + } + } + + for (offset, grapheme) in text[fragment_start..line_end].grapheme_indices(true) { + let grapheme_start = fragment_start + offset; + while word_index < words.len() && words[word_index].range.end <= grapheme_start { + word_index += 1; + consumed_word_width = 0; + } + + let grapheme_end = grapheme_start + grapheme.len(); + let is_whitespace = grapheme.chars().all(is_breakable_space); + if !is_whitespace && line_has_text && previous_was_whitespace { + let word_end = words + .get(word_index) + .filter(|word| word.range.contains(&grapheme_start)) + .map_or(line_end, |word| word.range.end.min(line_end)); + if line_width + display_width(&text[grapheme_start..word_end]) > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + } + + let grapheme_width = display_width(grapheme); + let hanging_space = line_has_text + && grapheme_end < logical_content_end + && grapheme.chars().all(is_hangable_space); + if !hanging_space && line_width > 0 && line_width + grapheme_width > width { + lines.push(line_start..grapheme_start + 1); + line_start = grapheme_start; + line_width = 0; + line_has_text = false; + } + line_width += grapheme_width; + line_has_text |= !is_whitespace; + previous_was_whitespace = is_whitespace; + + if words + .get(word_index) + .is_some_and(|word| word.range.contains(&grapheme_start)) + { + consumed_word_width += grapheme_width; + } + } + + if matches!(text.as_bytes().get(line_end), None | Some(b'\n')) { + lines.push(line_start..line_end + 1); + if line_width >= width { + lines.push(line_end..line_end + 1); + } + line_width = 0; + line_has_text = false; + line_active = false; + previous_was_whitespace = false; + } + processed_end = line_end; + } + + lines +} + +pub(super) fn cursor_position( + text: &str, + lines: &[Range], + width: u16, + pos: usize, +) -> Option<(usize, usize)> { + let row = lines + .partition_point(|line| line.start <= pos) + .checked_sub(1)?; + let before_cursor = visible_prefix(&text[lines[row].start..pos], width); + let before_cursor = text_for_display(before_cursor); + let col = display_width(before_cursor.as_ref()); + let width = usize::from(width); + if col >= width + && lines + .get(row + 1) + .is_some_and(|next| next.start == lines[row].end - 1) + { + Some((row + 1, 0)) + } else { + Some((row, col.min(width.saturating_sub(1)))) + } +} + +#[cfg(test)] +#[path = "wrapping_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping_tests.rs b/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping_tests.rs new file mode 100644 index 000000000..fb3ce5181 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/textarea/wrapping_tests.rs @@ -0,0 +1,30 @@ +use super::{cursor_position, visible_prefix, wrapped_lines}; + +#[test] +fn wrapped_lines_keep_utf8_boundaries_and_reserve_cursor_rows() { + let text = "你好 世界"; + let lines = wrapped_lines(text, 5); + assert!(!lines.is_empty()); + assert!(lines.iter().all(|range| text.is_char_boundary(range.start))); + assert!(lines + .iter() + .all(|range| text.is_char_boundary(range.end.saturating_sub(1).min(text.len())))); + assert_eq!( + cursor_position(text, &lines, 5, text.len()), + Some((lines.len() - 1, 4)) + ); +} + +#[test] +fn soft_break_spaces_hang_on_the_previous_row() { + assert_eq!(visible_prefix("a b c d e f", 5), "a b c d e "); + assert_eq!(visible_prefix("hello ", 5), "hello "); +} + +#[test] +fn non_breaking_spaces_stay_inside_the_word() { + let text = "one\u{a0}two"; + let lines = wrapped_lines(text, 5); + assert!(lines.len() >= 2); + assert!(lines.iter().all(|range| text.is_char_boundary(range.start))); +} diff --git a/lime-rs/crates/tui/src/clipboard_copy.rs b/lime-rs/crates/tui/src/clipboard_copy.rs index 658ab9b81..eca7abf9e 100644 --- a/lime-rs/crates/tui/src/clipboard_copy.rs +++ b/lime-rs/crates/tui/src/clipboard_copy.rs @@ -463,13 +463,11 @@ mod tests { #[test] fn tmux_requires_clipboard_forwarding_capability() { - assert!( - tmux_clipboard_copy_ready( - || Ok("external\n".to_string()), - || Ok("193: Ms: (string) \\033]52\n".to_string()), - ) - .is_ok() - ); + assert!(tmux_clipboard_copy_ready( + || Ok("external\n".to_string()), + || Ok("193: Ms: (string) \\033]52\n".to_string()), + ) + .is_ok()); assert_eq!( tmux_clipboard_copy_ready( || Ok("off\n".to_string()), diff --git a/lime-rs/crates/tui/src/collaboration_modes.rs b/lime-rs/crates/tui/src/collaboration_modes.rs new file mode 100644 index 000000000..fd637cb57 --- /dev/null +++ b/lime-rs/crates/tui/src/collaboration_modes.rs @@ -0,0 +1,139 @@ +//! Codex-shaped collaboration mode discovery and selection helpers. + +use agent_protocol::{CollaborationMode, CollaborationModeSettings, ModeKind}; +use app_server_protocol::protocol::v2::CollaborationModeMask; + +use crate::model_catalog::ModelCatalog; + +fn filtered_presets(model_catalog: &ModelCatalog) -> Vec { + model_catalog + .collaboration_modes + .clone() + .into_iter() + .filter(|mask| mask.mode.is_some()) + .collect() +} + +pub(crate) fn default_mask(model_catalog: &ModelCatalog) -> Option { + let presets = filtered_presets(model_catalog); + presets + .iter() + .find(|mask| mask.mode == Some(ModeKind::Default)) + .cloned() + .or_else(|| presets.into_iter().next()) +} + +pub(crate) fn mask_for_kind( + model_catalog: &ModelCatalog, + kind: ModeKind, +) -> Option { + filtered_presets(model_catalog) + .into_iter() + .find(|mask| mask.mode == Some(kind)) +} + +/// Cycle to the next server-advertised collaboration mode in list order. +pub(crate) fn next_mask( + model_catalog: &ModelCatalog, + current: Option<&CollaborationModeMask>, +) -> Option { + let presets = filtered_presets(model_catalog); + if presets.is_empty() { + return None; + } + let current_mode = current.and_then(|mask| mask.mode); + let next_index = presets + .iter() + .position(|mask| mask.mode == current_mode) + .map_or(0, |index| (index + 1) % presets.len()); + presets.get(next_index).cloned() +} + +pub(crate) fn default_mode_mask(model_catalog: &ModelCatalog) -> Option { + mask_for_kind(model_catalog, ModeKind::Default) +} + +pub(crate) fn plan_mask(model_catalog: &ModelCatalog) -> Option { + mask_for_kind(model_catalog, ModeKind::Plan) +} + +pub(crate) fn to_mode( + mask: &CollaborationModeMask, + fallback_model: Option<&str>, + fallback_effort: Option<&str>, +) -> Option { + let mode = mask.mode?; + let model = mask.model.as_deref().or(fallback_model)?.to_string(); + let reasoning_effort = match &mask.reasoning_effort { + Some(effort) => effort.clone(), + None => fallback_effort.map(str::to_string), + }; + Some(CollaborationMode { + mode, + settings: CollaborationModeSettings { + model, + reasoning_effort, + developer_instructions: None, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mask(name: &str, mode: ModeKind, model: Option<&str>) -> CollaborationModeMask { + CollaborationModeMask { + name: name.to_string(), + mode: Some(mode), + model: model.map(str::to_string), + reasoning_effort: Some(Some("high".to_string())), + } + } + + #[test] + fn mode_selection_prefers_default_and_cycles_in_server_order() { + let catalog = ModelCatalog::default().with_collaboration_modes(vec![ + mask("plan", ModeKind::Plan, Some("planner")), + mask("default", ModeKind::Default, Some("default")), + ]); + + assert_eq!(default_mask(&catalog).unwrap().name, "default"); + assert_eq!(default_mode_mask(&catalog).unwrap().name, "default"); + assert_eq!(plan_mask(&catalog).unwrap().name, "plan"); + assert_eq!( + next_mask(&catalog, Some(&mask("plan", ModeKind::Plan, None))) + .unwrap() + .name, + "default" + ); + } + + #[test] + fn to_mode_uses_mask_values_and_fallbacks() { + let plan = mask("plan", ModeKind::Plan, None); + let mode = to_mode(&plan, Some("fallback-model"), Some("medium")).expect("mode"); + assert_eq!(mode.mode, ModeKind::Plan); + assert_eq!(mode.settings.model, "fallback-model"); + assert_eq!(mode.settings.reasoning_effort.as_deref(), Some("high")); + } + + #[test] + fn to_mode_preserves_an_explicit_effort_clear() { + let mask = CollaborationModeMask { + name: "default".to_string(), + mode: Some(ModeKind::Default), + model: None, + reasoning_effort: Some(None), + }; + let mode = to_mode(&mask, Some("fallback-model"), Some("high")).expect("mode"); + assert_eq!(mode.settings.reasoning_effort, None); + } + + #[test] + fn empty_catalog_has_no_mode() { + let catalog = ModelCatalog::default(); + assert!(default_mask(&catalog).is_none()); + assert!(next_mask(&catalog, None).is_none()); + } +} diff --git a/lime-rs/crates/tui/src/command_popup.rs b/lime-rs/crates/tui/src/command_popup.rs index 1399f7dce..b5278b4ed 100644 --- a/lime-rs/crates/tui/src/command_popup.rs +++ b/lime-rs/crates/tui/src/command_popup.rs @@ -1,13 +1,13 @@ use crossterm::event::{Event, KeyCode, KeyEventKind}; -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; -use crate::slash_command::{SlashCommand, command_filter}; +use crate::slash_command::{command_filter, SlashCommand}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum CommandPopupAction { @@ -158,8 +158,8 @@ pub(crate) fn render( mod tests { use super::*; use crossterm::event::{KeyEvent, KeyModifiers}; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::Terminal; #[test] fn filters_by_prefix_and_wraps_selection() { @@ -169,8 +169,8 @@ mod tests { popup.handle_event(&Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE,))), CommandPopupAction::Consumed ); - assert_eq!(popup.selected(), Some(SlashCommand::Copy)); - assert!(popup.update("/p")); + assert_eq!(popup.selected(), Some(SlashCommand::Resume)); + assert!(popup.update("/per")); assert_eq!(popup.commands(), vec![SlashCommand::Permissions]); assert_eq!(popup.selected(), Some(SlashCommand::Permissions)); assert!(!popup.update("/unknown")); @@ -194,9 +194,9 @@ mod tests { #[test] fn test_backend_renders_commands_and_localized_descriptions() { let popup = CommandPopup::for_composer("/").expect("popup"); - let mut terminal = Terminal::new(TestBackend::new(72, 8)).expect("terminal"); + let mut terminal = Terminal::new(TestBackend::new(72, 10)).expect("terminal"); terminal - .draw(|frame| render(frame, Rect::new(0, 5, 72, 3), &popup, Locale::ZhCn)) + .draw(|frame| render(frame, Rect::new(0, 8, 72, 2), &popup, Locale::ZhCn)) .expect("draw"); let buffer = terminal.backend().buffer(); let text = (0..buffer.area.height) diff --git a/lime-rs/crates/tui/src/diff_render.rs b/lime-rs/crates/tui/src/diff_render.rs index fb36580da..39c1460aa 100644 --- a/lime-rs/crates/tui/src/diff_render.rs +++ b/lime-rs/crates/tui/src/diff_render.rs @@ -4,7 +4,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use unicode_segmentation::UnicodeSegmentation; -use crate::highlight::{CodeLineHighlighter, exceeds_highlight_limits}; +use crate::highlight::{exceeds_highlight_limits, CodeLineHighlighter}; use crate::line_truncation::truncate_line_to_width; use crate::width::display_width; @@ -226,11 +226,9 @@ fn render_line(line: DiffLine, number_width: usize, width: Option) -> Vec Span::raw(" ".repeat(number_width + 1)), Span::styled("⋮", Style::default().fg(Color::DarkGray)), ]); - return vec![ - width - .map(|width| truncate_line_to_width(marker.clone(), width)) - .unwrap_or(marker), - ]; + return vec![width + .map(|width| truncate_line_to_width(marker.clone(), width)) + .unwrap_or(marker)]; } let Some(number) = number else { return wrap_plain_line(Line::from(Span::styled(text, style)), width); @@ -667,13 +665,11 @@ mod tests { let inserted = &lines[2]; assert!(inserted.spans.len() > 3); - assert!( - inserted - .spans - .iter() - .skip(2) - .any(|span| span.content.contains("fn") && span.style.fg.is_some()) - ); + assert!(inserted + .spans + .iter() + .skip(2) + .any(|span| span.content.contains("fn") && span.style.fg.is_some())); } #[test] @@ -712,17 +708,15 @@ mod tests { content, "pub fn long_name(answer: usize) -> usize { answer + 1 }" ); - assert!( - lines - .iter() - .skip(1) - .flat_map(|line| &line.spans) - .any(|span| { - span.content.contains("fn") - && span.style.fg.is_some() - && span.style.fg != Some(Color::Green) - }) - ); + assert!(lines + .iter() + .skip(1) + .flat_map(|line| &line.spans) + .any(|span| { + span.content.contains("fn") + && span.style.fg.is_some() + && span.style.fg != Some(Color::Green) + })); } #[test] @@ -763,12 +757,10 @@ mod tests { let deleted = lines.last().expect("deleted line"); assert!(deleted.spans.len() > 3); - assert!( - deleted - .spans - .iter() - .skip(2) - .any(|span| span.content.contains("fn") && span.style.fg.is_some()) - ); + assert!(deleted + .spans + .iter() + .skip(2) + .any(|span| span.content.contains("fn") && span.style.fg.is_some())); } } diff --git a/lime-rs/crates/tui/src/entry.rs b/lime-rs/crates/tui/src/entry.rs index b92147022..a9a0c0387 100644 --- a/lime-rs/crates/tui/src/entry.rs +++ b/lime-rs/crates/tui/src/entry.rs @@ -8,7 +8,7 @@ use crate::diff_render; use crate::locale::Locale; use crate::markdown_render; use crate::projection::{EntryKind, EntryStatus, TranscriptEntry}; -use crate::terminal_hyperlinks::{HyperlinkLine, prefix_hyperlink_lines}; +use crate::terminal_hyperlinks::{prefix_hyperlink_lines, HyperlinkLine}; const COMMAND_OUTPUT_HEAD_LINES: usize = 50; const COMMAND_OUTPUT_TAIL_LINES: usize = 50; @@ -72,10 +72,10 @@ pub(crate) fn hyperlink_lines_with_locale( } let mut source = entry.text.lines(); let first = source.next().unwrap_or(""); - let first = if matches!(entry.kind, EntryKind::Tool | EntryKind::System) { - locale.detail(first) - } else { - first.to_string() + let first = match entry.kind { + EntryKind::Tool | EntryKind::System => locale.detail(first), + EntryKind::MultiAgent => locale.multi_agent(first), + _ => first.to_string(), }; let first = with_status_suffix(&first, entry.status, locale); let mut lines = vec![HyperlinkLine::new(format_line( @@ -287,27 +287,37 @@ mod tests { assert_eq!(mcp[0].spans[0].content.as_ref(), "@ "); assert_eq!(plan[0].spans[0].content.as_ref(), "• "); assert_eq!(multi_agent[0].spans[0].content.as_ref(), "& "); - assert!( - patch[1] - .spans - .iter() - .any(|span| span.style.fg == Some(Color::Green)) - ); + assert!(patch[1] + .spans + .iter() + .any(|span| span.style.fg == Some(Color::Green))); assert_eq!(command[1].spans[1].style.fg, Some(Color::DarkGray)); } + #[test] + fn multi_agent_titles_are_localized_at_the_render_boundary() { + let entry = entry(EntryKind::MultiAgent, "Spawned agent-1"); + let rendered = + hyperlink_lines_with_locale(&entry, Locale::ZhCn, Some(80), Path::new("/workspace")); + let text = rendered + .iter() + .flat_map(|line| line.line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect::(); + assert!(text.contains("已启动 agent-1")); + assert!(!text.contains("Spawned agent-1")); + } + #[test] fn plan_statuses_use_stable_checkbox_styles() { let completed = lines(&entry(EntryKind::Plan, "[x] inspect")); let running = lines(&entry(EntryKind::Plan, "[~] test")); let pending = lines(&entry(EntryKind::Plan, "[ ] ship")); - assert!( - completed[0].spans[1] - .style - .add_modifier - .contains(Modifier::CROSSED_OUT) - ); + assert!(completed[0].spans[1] + .style + .add_modifier + .contains(Modifier::CROSSED_OUT)); assert_eq!(running[0].spans[1].style.fg, Some(Color::Cyan)); assert_eq!(pending[0].spans[1].style.fg, Some(Color::DarkGray)); } @@ -345,12 +355,10 @@ mod tests { span.style.fg == Some(Color::DarkGray) && span.style.add_modifier.contains(Modifier::BOLD) })); - assert!( - rendered - .iter() - .flat_map(|line| &line.spans) - .any(|span| span.content == "src/lib.rs" && span.style.fg == Some(Color::Cyan)) - ); + assert!(rendered + .iter() + .flat_map(|line| &line.spans) + .any(|span| span.content == "src/lib.rs" && span.style.fg == Some(Color::Cyan))); } #[test] diff --git a/lime-rs/crates/tui/src/external_editor.rs b/lime-rs/crates/tui/src/external_editor.rs index 2f4d1d098..068530d75 100644 --- a/lime-rs/crates/tui/src/external_editor.rs +++ b/lime-rs/crates/tui/src/external_editor.rs @@ -3,7 +3,7 @@ use std::fs; use std::path::Path; use std::process::Stdio; -use anyhow::{Context, Result, bail}; +use anyhow::{bail, Context, Result}; use tokio::process::Command; /// Open the user's configured editor and return the edited draft. @@ -178,11 +178,9 @@ mod tests { .expect_err("non-zero editor must fail"); assert!(error.to_string().contains("exited with status")); - assert!( - fs::read_dir(directory.path()) - .expect("directory listing") - .filter_map(Result::ok) - .all(|entry| !entry.file_name().to_string_lossy().starts_with("lime-tui-")) - ); + assert!(fs::read_dir(directory.path()) + .expect("directory listing") + .filter_map(Result::ok) + .all(|entry| !entry.file_name().to_string_lossy().starts_with("lime-tui-"))); } } diff --git a/lime-rs/crates/tui/src/highlight.rs b/lime-rs/crates/tui/src/highlight.rs index 815c01503..8a5b68010 100644 --- a/lime-rs/crates/tui/src/highlight.rs +++ b/lime-rs/crates/tui/src/highlight.rs @@ -224,12 +224,10 @@ mod tests { fn unknown_language_falls_back_to_plain_text() { let lines = highlight_code_to_lines("some random text", "xyzlang"); assert_eq!(reconstructed(&lines), "some random text"); - assert!( - lines - .iter() - .flat_map(|line| &line.spans) - .all(|span| span.style == Style::default()) - ); + assert!(lines + .iter() + .flat_map(|line| &line.spans) + .all(|span| span.style == Style::default())); } #[test] @@ -242,12 +240,10 @@ mod tests { #[test] fn crlf_does_not_leak_carriage_returns() { let lines = highlight_code_to_lines("fn main() {\r\n}\r\n", "rust"); - assert!( - lines - .iter() - .flat_map(|line| &line.spans) - .all(|span| !span.content.contains('\r')) - ); + assert!(lines + .iter() + .flat_map(|line| &line.spans) + .all(|span| !span.content.contains('\r'))); } #[test] diff --git a/lime-rs/crates/tui/src/key_hint.rs b/lime-rs/crates/tui/src/key_hint.rs new file mode 100644 index 000000000..1f4e37900 --- /dev/null +++ b/lime-rs/crates/tui/src/key_hint.rs @@ -0,0 +1,42 @@ +//! Key input matching shared by TUI selection surfaces. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +/// Returns whether an event should be treated as literal text input. +pub(crate) fn is_plain_text_key_event(event: KeyEvent) -> bool { + matches!( + event, + KeyEvent { + code: KeyCode::Char(ch), + modifiers, + .. + } if !ch.is_ascii_control() + && !modifiers.contains(KeyModifiers::CONTROL) + && !modifiers.contains(KeyModifiers::ALT) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shifted_characters_are_plain_text() { + assert!(is_plain_text_key_event(KeyEvent::new( + KeyCode::Char('G'), + KeyModifiers::SHIFT, + ))); + } + + #[test] + fn control_and_alt_characters_are_not_plain_text() { + assert!(!is_plain_text_key_event(KeyEvent::new( + KeyCode::Char('g'), + KeyModifiers::CONTROL, + ))); + assert!(!is_plain_text_key_event(KeyEvent::new( + KeyCode::Char('g'), + KeyModifiers::ALT, + ))); + } +} diff --git a/lime-rs/crates/tui/src/keymap.rs b/lime-rs/crates/tui/src/keymap.rs new file mode 100644 index 000000000..7e19d7c06 --- /dev/null +++ b/lime-rs/crates/tui/src/keymap.rs @@ -0,0 +1,54 @@ +//! Stable key bindings for the Codex-shaped Agents Overview. + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct AgentsKeymap; + +impl AgentsKeymap { + pub(crate) fn resume(self, key: KeyEvent) -> bool { + Self::control(key, 'o') + } + + pub(crate) fn search(self, key: KeyEvent) -> bool { + Self::control(key, 'f') + } + + pub(crate) fn new_task(self, key: KeyEvent) -> bool { + Self::control(key, 'n') + } + + pub(crate) fn rename(self, key: KeyEvent) -> bool { + Self::control(key, 'r') + } + + pub(crate) fn stop(self, key: KeyEvent) -> bool { + Self::control(key, 'x') + } + + pub(crate) fn toggle_grouping(self, key: KeyEvent) -> bool { + Self::control(key, 's') + } + + fn control(key: KeyEvent, character: char) -> bool { + key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char(character) + } +} + +#[cfg(test)] +mod tests { + use super::AgentsKeymap; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn codex_agents_defaults_are_stable() { + let keymap = AgentsKeymap; + assert!(keymap.resume(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL))); + assert!(keymap.search(KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL))); + assert!(keymap.new_task(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL))); + assert!(keymap.rename(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::CONTROL))); + assert!(keymap.stop(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL))); + assert!(keymap.toggle_grouping(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL,))); + assert!(!keymap.resume(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE))); + } +} diff --git a/lime-rs/crates/tui/src/lib.rs b/lime-rs/crates/tui/src/lib.rs index b364871f4..8ef4e54c6 100644 --- a/lime-rs/crates/tui/src/lib.rs +++ b/lime-rs/crates/tui/src/lib.rs @@ -3,16 +3,21 @@ mod app_server_session; mod bottom_pane; mod clipboard_copy; mod clipboard_paste; +mod collaboration_modes; mod command_popup; mod diff_render; mod entry; mod external_editor; mod highlight; +mod key_hint; +mod keymap; mod line_truncation; mod locale; mod markdown; mod markdown_render; +mod model_catalog; mod model_picker; +mod multi_agents; mod pager_overlay; mod pending_input_preview; mod projection; @@ -34,7 +39,8 @@ mod thread_transcript; mod transcript_reflow; mod tui; mod view; +mod viewport; mod width; mod wrapping; -pub use runtime::{ExecOptions, ExecResult, TuiOptions, run_exec, run_resume, run_tui}; +pub use runtime::{run_exec, run_resume, run_tui, ExecOptions, ExecResult, TuiOptions}; diff --git a/lime-rs/crates/tui/src/locale.rs b/lime-rs/crates/tui/src/locale.rs index fa345282e..4acaaa928 100644 --- a/lime-rs/crates/tui/src/locale.rs +++ b/lime-rs/crates/tui/src/locale.rs @@ -156,6 +156,11 @@ impl Locale { (Self::EnUs, SlashCommand::Model) => "choose a model", (Self::JaJp, SlashCommand::Model) => "モデルを選択", (Self::KoKr, SlashCommand::Model) => "모델 선택", + (Self::ZhCn, SlashCommand::Plan) => "切换到计划模式", + (Self::ZhTw, SlashCommand::Plan) => "切換至計畫模式", + (Self::EnUs, SlashCommand::Plan) => "switch to Plan mode", + (Self::JaJp, SlashCommand::Plan) => "計画モードに切り替え", + (Self::KoKr, SlashCommand::Plan) => "계획 모드로 전환", (Self::ZhCn, SlashCommand::Effort) => "设置推理强度", (Self::ZhTw, SlashCommand::Effort) => "設定推理強度", (Self::EnUs, SlashCommand::Effort) => "set the reasoning effort", @@ -176,6 +181,25 @@ impl Locale { (Self::EnUs, SlashCommand::Copy) => "copy the last response", (Self::JaJp, SlashCommand::Copy) => "最後の応答をコピー", (Self::KoKr, SlashCommand::Copy) => "마지막 응답 복사", + (Self::ZhCn, SlashCommand::Agents) => "查看和切换所有活跃 Agent 会话", + (Self::ZhTw, SlashCommand::Agents) => "檢視和切換所有活躍 Agent 工作階段", + (Self::EnUs, SlashCommand::Agents) => { + "view and switch between all active agent sessions" + } + (Self::JaJp, SlashCommand::Agents) => { + "すべてのアクティブな Agent セッションを表示して切り替え" + } + (Self::KoKr, SlashCommand::Agents) => "모든 활성 에이전트 세션 보기 및 전환", + (Self::ZhCn, SlashCommand::MultiAgents) => "切换子 Agent 会话", + (Self::ZhTw, SlashCommand::MultiAgents) => "切換子 Agent 工作階段", + (Self::EnUs, SlashCommand::MultiAgents) => "switch between sub-agent threads", + (Self::JaJp, SlashCommand::MultiAgents) => "サブ Agent スレッドを切り替え", + (Self::KoKr, SlashCommand::MultiAgents) => "하위 에이전트 스레드 전환", + (Self::ZhCn, SlashCommand::Resume) => "恢复之前的会话", + (Self::ZhTw, SlashCommand::Resume) => "恢復先前的工作階段", + (Self::EnUs, SlashCommand::Resume) => "resume a previous session", + (Self::JaJp, SlashCommand::Resume) => "以前のセッションを再開", + (Self::KoKr, SlashCommand::Resume) => "이전 세션 재개", } } @@ -317,6 +341,13 @@ impl Locale { Self::JaJp => "失敗", Self::KoKr => "실패", }, + "closed" => match self { + Self::ZhCn => "已关闭", + Self::ZhTw => "已關閉", + Self::EnUs => "closed", + Self::JaJp => "終了", + Self::KoKr => "닫힘", + }, "interrupted" => match self { Self::ZhCn => "已中断", Self::ZhTw => "已中斷", @@ -324,6 +355,48 @@ impl Locale { Self::JaJp => "中断", Self::KoKr => "중단됨", }, + "background task started" => match self { + Self::ZhCn => "后台任务已启动", + Self::ZhTw => "背景工作已啟動", + Self::EnUs => "background task started", + Self::JaJp => "バックグラウンドタスクを開始しました", + Self::KoKr => "백그라운드 작업 시작됨", + }, + "background task failed" => match self { + Self::ZhCn => "后台任务失败", + Self::ZhTw => "背景工作失敗", + Self::EnUs => "background task failed", + Self::JaJp => "バックグラウンドタスクに失敗しました", + Self::KoKr => "백그라운드 작업 실패", + }, + "background task is idle" => match self { + Self::ZhCn => "后台任务未运行", + Self::ZhTw => "背景工作未執行", + Self::EnUs => "background task is idle", + Self::JaJp => "バックグラウンドタスクは待機中です", + Self::KoKr => "백그라운드 작업 대기 중", + }, + "stopping background turn" => match self { + Self::ZhCn => "正在停止后台回合", + Self::ZhTw => "正在停止背景回合", + Self::EnUs => "stopping background turn", + Self::JaJp => "バックグラウンドターンを停止中", + Self::KoKr => "백그라운드 턴 중지 중", + }, + "agent renamed" => match self { + Self::ZhCn => "Agent 已改名", + Self::ZhTw => "Agent 已改名", + Self::EnUs => "agent renamed", + Self::JaJp => "Agent の名前を変更しました", + Self::KoKr => "에이전트 이름 변경됨", + }, + "resume picker is available from /resume" => match self { + Self::ZhCn => "请使用 /resume 打开会话选择器", + Self::ZhTw => "請使用 /resume 開啟工作階段選擇器", + Self::EnUs => "resume picker is available from /resume", + Self::JaJp => "/resume から再開ピッカーを開けます", + Self::KoKr => "/resume에서 재개 선택기를 열 수 있음", + }, "declined" => match self { Self::ZhCn => "已拒绝", Self::ZhTw => "已拒絕", @@ -338,6 +411,55 @@ impl Locale { Self::JaJp => "設定を更新しました", Self::KoKr => "설정이 업데이트됨", }, + "collaboration mode updated" => match self { + Self::ZhCn => "协作模式已更新", + Self::ZhTw => "協作模式已更新", + Self::EnUs => "collaboration mode updated", + Self::JaJp => "コラボレーションモードを更新しました", + Self::KoKr => "협업 모드가 업데이트됨", + }, + "plan mode" => match self { + Self::ZhCn => "计划模式", + Self::ZhTw => "計畫模式", + Self::EnUs => "plan mode", + Self::JaJp => "計画モード", + Self::KoKr => "계획 모드", + }, + "plan mode unavailable on this server" => match self { + Self::ZhCn => "此服务器不支持计划模式", + Self::ZhTw => "此伺服器不支援計畫模式", + Self::EnUs => "plan mode unavailable on this server", + Self::JaJp => "このサーバーでは計画モードを利用できません", + Self::KoKr => "이 서버에서는 계획 모드를 사용할 수 없음", + }, + "switched agent" => match self { + Self::ZhCn => "已切换 Agent", + Self::ZhTw => "已切換 Agent", + Self::EnUs => "switched agent", + Self::JaJp => "Agent を切り替えました", + Self::KoKr => "에이전트 전환됨", + }, + "sub-agent thread is parent-owned" => match self { + Self::ZhCn => "子 Agent 会话由父会话控制", + Self::ZhTw => "子 Agent 工作階段由父工作階段控制", + Self::EnUs => "sub-agent thread is parent-owned", + Self::JaJp => "サブ Agent スレッドは親が管理します", + Self::KoKr => "하위 에이전트 스레드는 부모가 제어함", + }, + "agent switch failed" => match self { + Self::ZhCn => "切换 Agent 失败", + Self::ZhTw => "切換 Agent 失敗", + Self::EnUs => "agent switch failed", + Self::JaJp => "Agent の切り替えに失敗しました", + Self::KoKr => "에이전트 전환 실패", + }, + "no sub-agents available" => match self { + Self::ZhCn => "暂无可用的子 Agent", + Self::ZhTw => "目前沒有可用的子 Agent", + Self::EnUs => "no sub-agents available", + Self::JaJp => "利用可能なサブ Agent がありません", + Self::KoKr => "사용 가능한 하위 에이전트가 없음", + }, "choose model" => match self { Self::ZhCn => "请选择模型", Self::ZhTw => "請選擇模型", @@ -464,6 +586,69 @@ impl Locale { rest.map_or_else(|| label.to_string(), |rest| format!("{label}: {rest}")) } + pub(crate) fn multi_agent(self, text: &str) -> String { + if text == "Finished waiting" { + return match self { + Self::ZhCn => "等待完成".to_string(), + Self::ZhTw => "等待完成".to_string(), + Self::EnUs => text.to_string(), + Self::JaJp => "待機が完了しました".to_string(), + Self::KoKr => "대기 완료".to_string(), + }; + } + + const PREFIXES: &[(&str, &str, &str, &str, &str)] = &[ + ("Spawned ", "已启动 ", "已啟動 ", "起動: ", "생성됨 "), + ( + "Sent input to ", + "已向其发送输入 ", + "已向其傳送輸入 ", + "入力送信: ", + "입력 전송: ", + ), + ( + "Resuming ", + "正在恢复 ", + "正在恢復 ", + "再開中: ", + "재개 중: ", + ), + ("Resumed ", "已恢复 ", "已恢復 ", "再開済み: ", "재개됨 "), + ( + "Waiting for ", + "正在等待 ", + "正在等待 ", + "待機中: ", + "대기 중: ", + ), + ("Closed ", "已关闭 ", "已關閉 ", "終了: ", "닫힘 "), + ("Started ", "已开始 ", "已開始 ", "開始: ", "시작됨 "), + ( + "Interacted with ", + "已与其交互 ", + "已與其互動 ", + "操作: ", + "상호작용: ", + ), + ("Interrupted ", "已中断 ", "已中斷 ", "中断: ", "중단됨 "), + ]; + PREFIXES + .iter() + .find_map(|(prefix, zh_cn, zh_tw, ja, ko)| { + text.strip_prefix(prefix).map(|rest| { + let label = match self { + Self::ZhCn => zh_cn, + Self::ZhTw => zh_tw, + Self::EnUs => prefix, + Self::JaJp => ja, + Self::KoKr => ko, + }; + format!("{label}{rest}") + }) + }) + .unwrap_or_else(|| text.to_string()) + } + pub(crate) fn detail(self, detail: &str) -> String { const PREFIXES: &[(&str, &str, &str, &str, &str)] = &[ ("exit ", "退出 ", "退出 ", "exit ", "종료 "), @@ -726,6 +911,113 @@ impl Locale { } } + pub(crate) fn agent_picker_title(self) -> &'static str { + match self { + Self::ZhCn => "选择 Agent", + Self::ZhTw => "選擇 Agent", + Self::EnUs => "Choose an agent", + Self::JaJp => "Agent を選択", + Self::KoKr => "에이전트 선택", + } + } + + pub(crate) fn agent_picker_empty(self) -> &'static str { + match self { + Self::ZhCn => "暂无可用的子 Agent。按 Esc 取消", + Self::ZhTw => "目前沒有可用的子 Agent。按 Esc 取消", + Self::EnUs => "No sub-agents available. Esc cancel", + Self::JaJp => "利用可能なサブ Agent がありません。Esc でキャンセル", + Self::KoKr => "사용 가능한 하위 에이전트가 없습니다. Esc로 취소", + } + } + + pub(crate) fn agent_picker_footer(self) -> &'static str { + match self { + Self::ZhCn => "上下移动 Enter 查看 Esc 取消", + Self::ZhTw => "上下移動 Enter 檢視 Esc 取消", + Self::EnUs => "Up/Down move Enter view Esc cancel", + Self::JaJp => "上下移動 Enter 表示 Esc キャンセル", + Self::KoKr => "위/아래 이동 Enter 보기 Esc 취소", + } + } + + pub(crate) fn agents_overview_title(self) -> &'static str { + match self { + Self::ZhCn => "Agent 控制中心", + Self::ZhTw => "Agent 控制中心", + Self::EnUs => "Agent command center", + Self::JaJp => "Agent コマンドセンター", + Self::KoKr => "에이전트 명령 센터", + } + } + + pub(crate) fn agents_overview_footer(self) -> &'static str { + match self { + Self::ZhCn => { + "上下移动 · Enter 打开 · / 或 Ctrl+F 搜索 · Ctrl+N 新建 · Ctrl+R 改名 · Ctrl+X 停止 · Ctrl+S 分组 · r 刷新 · Esc 返回" + } + Self::ZhTw => { + "上下移動 · Enter 開啟 · / 或 Ctrl+F 搜尋 · Ctrl+N 新建 · Ctrl+R 改名 · Ctrl+X 停止 · Ctrl+S 分組 · r 重新整理 · Esc 返回" + } + Self::EnUs => { + "Up/Down navigate · Enter open · / or Ctrl+F search · Ctrl+N new · Ctrl+R rename · Ctrl+X stop · Ctrl+S group · r refresh · Esc back" + } + Self::JaJp => { + "上下移動 · Enter 開く · / または Ctrl+F 検索 · Ctrl+N 新規 · Ctrl+R 名前変更 · Ctrl+X 停止 · Ctrl+S グループ · r 更新 · Esc 戻る" + } + Self::KoKr => { + "위/아래 이동 · Enter 열기 · / 또는 Ctrl+F 검색 · Ctrl+N 새 작업 · Ctrl+R 이름 변경 · Ctrl+X 중지 · Ctrl+S 그룹 · r 새로 고침 · Esc 뒤로" + } + } + } + + pub(crate) fn agents_overview_input_prefix(self, rename: bool) -> &'static str { + match (self, rename) { + (Self::ZhCn, true) => "改名", + (Self::ZhCn, false) => "新建任务", + (Self::ZhTw, true) => "改名", + (Self::ZhTw, false) => "新建工作", + (Self::EnUs, true) => "Rename", + (Self::EnUs, false) => "New task", + (Self::JaJp, true) => "名前変更", + (Self::JaJp, false) => "新しいタスク", + (Self::KoKr, true) => "이름 변경", + (Self::KoKr, false) => "새 작업", + } + } + + pub(crate) fn agents_overview_search_prefix(self) -> &'static str { + match self { + Self::ZhCn => "搜索", + Self::ZhTw => "搜尋", + Self::EnUs => "Search", + Self::JaJp => "検索", + Self::KoKr => "검색", + } + } + + pub(crate) fn agents_overview_group_label(self, group: &'static str) -> &'static str { + match (self, group) { + (Self::ZhCn, "need input") => "需要输入", + (Self::ZhTw, "need input") => "需要輸入", + (Self::JaJp, "need input") => "入力待ち", + (Self::KoKr, "need input") => "입력 필요", + (Self::ZhCn, "working") => "运行中", + (Self::ZhTw, "working") => "執行中", + (Self::JaJp, "working") => "実行中", + (Self::KoKr, "working") => "작업 중", + (Self::ZhCn, "ready") => "就绪", + (Self::ZhTw, "ready") => "就緒", + (Self::JaJp, "ready") => "準備完了", + (Self::KoKr, "ready") => "준비됨", + (Self::ZhCn, "finished") => "已完成", + (Self::ZhTw, "finished") => "已完成", + (Self::JaJp, "finished") => "完了", + (Self::KoKr, "finished") => "완료", + (_, value) => value, + } + } + pub(crate) fn resume_title(self) -> &'static str { match self { Self::ZhCn => "选择会话", @@ -933,6 +1225,16 @@ impl Locale { } } + pub(crate) fn resume_transcript_hint(self) -> &'static str { + match self { + Self::ZhCn => "Ctrl+T 查看完整记录", + Self::ZhTw => "Ctrl+T 檢視完整記錄", + Self::EnUs => "Ctrl+T view full transcript", + Self::JaJp => "Ctrl+T 完全な履歴を表示", + Self::KoKr => "Ctrl+T 전체 대화 기록 보기", + } + } + pub(crate) fn created_label(self) -> &'static str { match self { Self::ZhCn => "创建时间", @@ -1055,6 +1357,93 @@ mod tests { ); } + #[test] + fn collaboration_mode_statuses_cover_all_product_locales() { + let cases = [ + ( + Locale::ZhCn, + "协作模式已更新", + "计划模式", + "此服务器不支持计划模式", + ), + ( + Locale::ZhTw, + "協作模式已更新", + "計畫模式", + "此伺服器不支援計畫模式", + ), + ( + Locale::EnUs, + "collaboration mode updated", + "plan mode", + "plan mode unavailable on this server", + ), + ( + Locale::JaJp, + "コラボレーションモードを更新しました", + "計画モード", + "このサーバーでは計画モードを利用できません", + ), + ( + Locale::KoKr, + "협업 모드가 업데이트됨", + "계획 모드", + "이 서버에서는 계획 모드를 사용할 수 없음", + ), + ]; + + for (locale, updated, plan, unavailable) in cases { + assert_eq!(locale.status("collaboration mode updated"), updated); + assert_eq!(locale.status("plan mode"), plan); + assert_eq!( + locale.status("plan mode unavailable on this server"), + unavailable + ); + } + } + + #[test] + fn multi_agent_titles_cover_all_product_locales() { + let cases = [ + ( + Locale::ZhCn, + "已启动 agent-1", + "等待完成", + "已中断 `agent-1`", + ), + ( + Locale::ZhTw, + "已啟動 agent-1", + "等待完成", + "已中斷 `agent-1`", + ), + ( + Locale::EnUs, + "Spawned agent-1", + "Finished waiting", + "Interrupted `agent-1`", + ), + ( + Locale::JaJp, + "起動: agent-1", + "待機が完了しました", + "中断: `agent-1`", + ), + ( + Locale::KoKr, + "생성됨 agent-1", + "대기 완료", + "중단됨 `agent-1`", + ), + ]; + for (locale, spawned, waiting, interrupted) in cases { + assert_eq!(locale.multi_agent("Spawned agent-1"), spawned); + assert_eq!(locale.multi_agent("Finished waiting"), waiting); + assert_eq!(locale.multi_agent("Interrupted `agent-1`"), interrupted); + } + assert_eq!(Locale::EnUs.multi_agent("unknown event"), "unknown event"); + } + #[test] fn command_output_omission_markers_cover_all_product_locales() { let line_markers = [ @@ -1080,6 +1469,44 @@ mod tests { } } + #[test] + fn agents_overview_controls_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(!locale.agents_overview_title().is_empty()); + assert!(!locale.agents_overview_footer().is_empty()); + assert!(!locale.agents_overview_search_prefix().is_empty()); + assert!(!locale.agents_overview_group_label("need input").is_empty()); + } + } + + #[test] + fn agents_overview_task_statuses_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(!locale.agents_overview_input_prefix(false).is_empty()); + assert!(!locale.agents_overview_input_prefix(true).is_empty()); + assert!(!locale + .status("background task started: thread-1") + .is_empty()); + assert!(!locale.status("background task failed: offline").is_empty()); + assert!(!locale.status("agent renamed").is_empty()); + assert!(!locale + .status("resume picker is available from /resume") + .is_empty()); + } + } + #[test] fn image_clipboard_statuses_cover_all_product_locales() { let cases = [ @@ -1265,6 +1692,7 @@ mod tests { assert!(!locale.resume_density_label(false).is_empty()); assert!(!locale.resume_density_label(true).is_empty()); assert!(!locale.resume_expand_hint().is_empty()); + assert!(!locale.resume_transcript_hint().is_empty()); assert!(!locale.created_label().is_empty()); assert!(!locale.updated_label().is_empty()); assert!(!locale.resume_transcript_loading().is_empty()); diff --git a/lime-rs/crates/tui/src/markdown.rs b/lime-rs/crates/tui/src/markdown.rs index e124745eb..b9d32b7b0 100644 --- a/lime-rs/crates/tui/src/markdown.rs +++ b/lime-rs/crates/tui/src/markdown.rs @@ -627,19 +627,15 @@ mod tests { .find(|span| span.content == "https://example.com/guide") .expect("visible link destination"); assert_eq!(destination.style.fg, Some(Color::Cyan)); - assert!( - destination - .style - .add_modifier - .contains(Modifier::UNDERLINED) - ); + assert!(destination + .style + .add_modifier + .contains(Modifier::UNDERLINED)); assert_eq!(lines[0].hyperlinks.len(), 2); - assert!( - lines[0] - .hyperlinks - .iter() - .all(|link| link.destination == "https://example.com/guide") - ); + assert!(lines[0] + .hyperlinks + .iter() + .all(|link| link.destination == "https://example.com/guide")); } #[test] @@ -695,13 +691,11 @@ mod tests { plain(&lines), vec![" Name Count ", "━━━━━━ ━━━━━━━", " Lime 2 "] ); - assert!( - lines[0] - .line - .spans - .iter() - .any(|span| span.style.add_modifier.contains(Modifier::BOLD)) - ); + assert!(lines[0] + .line + .spans + .iter() + .any(|span| span.style.add_modifier.contains(Modifier::BOLD))); } #[test] diff --git a/lime-rs/crates/tui/src/markdown/table.rs b/lime-rs/crates/tui/src/markdown/table.rs index cc9f35655..427a7fcbd 100644 --- a/lime-rs/crates/tui/src/markdown/table.rs +++ b/lime-rs/crates/tui/src/markdown/table.rs @@ -5,7 +5,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use super::{TableRow, TableState}; -use crate::terminal_hyperlinks::{HyperlinkLine, wrap_hyperlink_line}; +use crate::terminal_hyperlinks::{wrap_hyperlink_line, HyperlinkLine}; use crate::width::display_width; const COLUMN_GAP: usize = 2; diff --git a/lime-rs/crates/tui/src/model_catalog.rs b/lime-rs/crates/tui/src/model_catalog.rs new file mode 100644 index 000000000..074b13229 --- /dev/null +++ b/lime-rs/crates/tui/src/model_catalog.rs @@ -0,0 +1,75 @@ +//! Server-owned model and collaboration mode catalog used by the TUI. + +use app_server_protocol::protocol::v2::{CollaborationModeMask, Model}; +use std::convert::Infallible; + +/// A snapshot of the model picker inputs returned by App Server. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct ModelCatalog { + pub(crate) models: Vec, + pub(crate) collaboration_modes: Vec, +} + +impl ModelCatalog { + pub(crate) fn new(models: Vec) -> Self { + Self { + models, + collaboration_modes: Vec::new(), + } + } + + pub(crate) fn with_collaboration_modes( + mut self, + collaboration_modes: Vec, + ) -> Self { + self.collaboration_modes = collaboration_modes; + self + } + + pub(crate) fn try_list_models(&self) -> Result, Infallible> { + Ok(self.models.clone()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::InputModality; + use app_server_protocol::CapabilitySnapshot; + + fn model(id: &str, provider: &str, is_default: bool) -> Model { + Model { + id: id.to_string(), + provider_id: provider.to_string(), + model: id.to_string(), + upgrade: None, + upgrade_info: None, + availability_nux: None, + display_name: id.to_string(), + description: String::new(), + hidden: false, + supported_reasoning_efforts: Vec::new(), + default_reasoning_effort: "medium".to_string(), + input_modalities: vec![InputModality::Text], + capability_snapshot: CapabilitySnapshot::default(), + context_window: None, + max_output_tokens: None, + supports_personality: false, + multi_agent_version: None, + additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), + default_service_tier: None, + is_default, + } + } + + #[test] + fn model_catalog_preserves_server_order() { + let catalog = ModelCatalog::new(vec![ + model("fast", "fixture", false), + model("default", "fixture", true), + ]); + + assert_eq!(catalog.try_list_models().unwrap(), catalog.models); + } +} diff --git a/lime-rs/crates/tui/src/model_picker.rs b/lime-rs/crates/tui/src/model_picker.rs index 3f861b770..b2569e65b 100644 --- a/lime-rs/crates/tui/src/model_picker.rs +++ b/lime-rs/crates/tui/src/model_picker.rs @@ -1,10 +1,10 @@ use app_server_protocol::protocol::v2::Model; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; @@ -223,11 +223,11 @@ pub(crate) fn render_with_locale( #[cfg(test)] mod tests { use super::*; - use app_server_protocol::CapabilitySnapshot; use app_server_protocol::protocol::v2::{InputModality, Model}; + use app_server_protocol::CapabilitySnapshot; use crossterm::event::{KeyEvent, KeyModifiers}; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::Terminal; fn model(id: &str, provider: &str, hidden: bool, is_default: bool) -> Model { Model { diff --git a/lime-rs/crates/tui/src/multi_agents.rs b/lime-rs/crates/tui/src/multi_agents.rs new file mode 100644 index 000000000..8897caf46 --- /dev/null +++ b/lime-rs/crates/tui/src/multi_agents.rs @@ -0,0 +1,466 @@ +//! Helpers for rendering and navigating multi-agent state in the TUI. +//! +//! This module owns the presentation shape for collaboration tool calls and sub-agent activity. +//! Thread scheduling, lifecycle, and persistence remain App Server responsibilities. + +use std::collections::HashMap; + +use app_server_protocol::protocol::v2::{ + CollabAgentState, CollabAgentStatus, CollabAgentTool, CollabAgentToolCallStatus, + ReasoningEffort, SubAgentActivityKind, ThreadItem, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; + +const COLLAB_PROMPT_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES: usize = 160; +const COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES: usize = 240; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SubAgentActivityDisplay { + pub(crate) thread_id: String, + pub(crate) agent_path: String, + pub(crate) is_running_hint: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AgentPickerThreadEntry { + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, + pub(crate) agent_path: Option, + pub(crate) is_running: bool, + pub(crate) is_closed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SpawnRequestSummary { + pub(crate) model: String, + pub(crate) reasoning_effort: ReasoningEffort, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MultiAgentHistoryCell { + pub(crate) title: String, + pub(crate) details: Vec, +} + +pub(crate) fn format_agent_picker_item_name( + agent_nickname: Option<&str>, + agent_role: Option<&str>, + is_primary: bool, +) -> String { + if is_primary { + return "Main [default]".to_string(); + } + + let agent_nickname = agent_nickname + .map(str::trim) + .filter(|nickname| !nickname.is_empty()); + let agent_role = agent_role.map(str::trim).filter(|role| !role.is_empty()); + match (agent_nickname, agent_role) { + (Some(agent_nickname), Some(agent_role)) => format!("{agent_nickname} [{agent_role}]"), + (Some(agent_nickname), None) => agent_nickname.to_string(), + (None, Some(agent_role)) => format!("[{agent_role}]"), + (None, None) => "Agent".to_string(), + } +} + +pub(crate) fn previous_agent_shortcut() -> KeyCode { + KeyCode::Left +} + +pub(crate) fn next_agent_shortcut() -> KeyCode { + KeyCode::Right +} + +pub(crate) fn previous_agent_shortcut_matches( + key_event: KeyEvent, + allow_word_motion_fallback: bool, +) -> bool { + (key_event.kind == KeyEventKind::Press + && key_event.code == previous_agent_shortcut() + && key_event.modifiers.contains(KeyModifiers::ALT)) + || (cfg!(target_os = "macos") + && allow_word_motion_fallback + && key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Char('b') + && key_event.modifiers.contains(KeyModifiers::ALT)) +} + +pub(crate) fn next_agent_shortcut_matches( + key_event: KeyEvent, + allow_word_motion_fallback: bool, +) -> bool { + (key_event.kind == KeyEventKind::Press + && key_event.code == next_agent_shortcut() + && key_event.modifiers.contains(KeyModifiers::ALT)) + || (cfg!(target_os = "macos") + && allow_word_motion_fallback + && key_event.kind == KeyEventKind::Press + && key_event.code == KeyCode::Char('f') + && key_event.modifiers.contains(KeyModifiers::ALT)) +} + +pub(crate) fn spawn_request_summary(item: &ThreadItem) -> Option { + match item { + ThreadItem::CollabAgentToolCall { + tool: CollabAgentTool::SpawnAgent, + model: Some(model), + reasoning_effort: Some(reasoning_effort), + .. + } => Some(SpawnRequestSummary { + model: model.clone(), + reasoning_effort: reasoning_effort.clone(), + }), + _ => None, + } +} + +pub(crate) fn tool_call_history_cell(item: &ThreadItem) -> Option { + let ThreadItem::CollabAgentToolCall { + tool, + status, + receiver_thread_ids, + prompt, + agents_states, + .. + } = item + else { + return None; + }; + + let receiver = receiver_thread_ids + .first() + .map(String::as_str) + .unwrap_or("agents"); + let title = match (tool, status) { + (CollabAgentTool::SpawnAgent, CollabAgentToolCallStatus::InProgress) => return None, + (CollabAgentTool::SpawnAgent, _) => format!("Spawned {receiver}"), + (CollabAgentTool::SendInput, CollabAgentToolCallStatus::InProgress) => return None, + (CollabAgentTool::SendInput, _) => format!("Sent input to {receiver}"), + (CollabAgentTool::ResumeAgent, CollabAgentToolCallStatus::InProgress) => { + format!("Resuming {receiver}") + } + (CollabAgentTool::ResumeAgent, _) => format!("Resumed {receiver}"), + (CollabAgentTool::Wait, CollabAgentToolCallStatus::InProgress) => { + if receiver_thread_ids.len() == 1 { + format!("Waiting for {receiver}") + } else { + format!("Waiting for {} agents", receiver_thread_ids.len()) + } + } + (CollabAgentTool::Wait, _) => "Finished waiting".to_string(), + (CollabAgentTool::CloseAgent, CollabAgentToolCallStatus::InProgress) => return None, + (CollabAgentTool::CloseAgent, _) => format!("Closed {receiver}"), + }; + + let mut details = Vec::new(); + if let Some(prompt) = prompt_line(prompt.as_deref().unwrap_or_default()) { + details.push(prompt); + } + if matches!(tool, CollabAgentTool::SpawnAgent) { + if let Some(spawn_request) = spawn_request_summary(item) { + details.push(format!( + "model: {} effort: {}", + compact_text(&spawn_request.model, COLLAB_PROMPT_PREVIEW_GRAPHEMES), + spawn_request.reasoning_effort.as_str() + )); + } + } + if matches!(tool, CollabAgentTool::Wait) + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + details.extend(wait_complete_lines(receiver_thread_ids, agents_states)); + } + if matches!(tool, CollabAgentTool::ResumeAgent) + && !matches!(status, CollabAgentToolCallStatus::InProgress) + { + details.push( + receiver_thread_ids + .first() + .and_then(|id| agents_states.get(id)) + .map_or_else( + || "Error - Agent resume failed".to_string(), + status_summary_text, + ), + ); + } + + Some(MultiAgentHistoryCell { title, details }) +} + +pub(crate) fn sub_agent_activity_display(item: &ThreadItem) -> Option { + let ThreadItem::SubAgentActivity { + kind, + agent_thread_id, + agent_path, + .. + } = item + else { + return None; + }; + let is_running_hint = match kind { + SubAgentActivityKind::Started => true, + SubAgentActivityKind::Interacted => return None, + SubAgentActivityKind::Interrupted => false, + }; + Some(SubAgentActivityDisplay { + thread_id: agent_thread_id.clone(), + agent_path: agent_path.clone(), + is_running_hint, + }) +} + +pub(crate) fn sub_agent_activity_summary(kind: SubAgentActivityKind, agent_path: &str) -> String { + let prefix = match kind { + SubAgentActivityKind::Started => "Started", + SubAgentActivityKind::Interacted => "Interacted with", + SubAgentActivityKind::Interrupted => "Interrupted", + }; + format!("{prefix} `{agent_path}`") +} + +pub(crate) fn collab_agent_status_label(status: CollabAgentStatus) -> &'static str { + match status { + CollabAgentStatus::PendingInit => "pending", + CollabAgentStatus::Running => "running", + CollabAgentStatus::Interrupted => "interrupted", + CollabAgentStatus::Completed => "completed", + CollabAgentStatus::Errored => "errored", + CollabAgentStatus::Shutdown => "shutdown", + CollabAgentStatus::NotFound => "not-found", + } +} + +pub(crate) fn collab_summary( + agents_states: &HashMap, + prompt: Option<&str>, + model: Option<&str>, + reasoning_effort: Option<&ReasoningEffort>, +) -> Vec { + let mut details = vec![format!("agents: {}", agents_states.len())]; + let mut counts = std::collections::BTreeMap::<&'static str, usize>::new(); + for state in agents_states.values() { + *counts + .entry(collab_agent_status_label(state.status)) + .or_default() += 1; + } + details.extend( + counts + .into_iter() + .map(|(label, count)| format!("{label}: {count}")), + ); + if let Some(model) = non_empty(model) { + details.push(format!("model: {}", compact_text(model, 160))); + } + if let Some(reasoning_effort) = reasoning_effort { + details.push(format!("effort: {}", reasoning_effort.as_str())); + } + if let Some(prompt) = non_empty(prompt) { + details.push(format!( + "prompt: {}", + compact_text(prompt, COLLAB_PROMPT_PREVIEW_GRAPHEMES) + )); + } + details +} + +/// Build the canonical summary for a collaboration item. +/// +/// Status/model facts are useful for every collaboration call, while the history cell owns +/// lifecycle-specific details such as per-agent wait results and resume errors. Keeping this +/// composition here means `ConversationProjection` only maps canonical items and does not need to +/// duplicate Codex's tool lifecycle branching. +pub(crate) fn collab_summary_for_item(item: &ThreadItem) -> Vec { + let ThreadItem::CollabAgentToolCall { + agents_states, + prompt, + model, + reasoning_effort, + .. + } = item + else { + return Vec::new(); + }; + + let mut summary = collab_summary( + agents_states, + prompt.as_deref(), + model.as_deref(), + reasoning_effort.as_ref(), + ); + let Some(cell) = tool_call_history_cell(item) else { + return summary; + }; + + for detail in cell.details { + // Prompt/model/effort are already represented by the stable key/value facts above. The + // history cell's remaining lines are lifecycle results that are not otherwise visible. + if detail.starts_with("model: ") + || summary + .iter() + .any(|existing| existing == &format!("prompt: {detail}")) + { + continue; + } + summary.push(detail); + } + summary +} + +fn wait_complete_lines( + receiver_thread_ids: &[String], + agents_states: &HashMap, +) -> Vec { + let mut ids = receiver_thread_ids.to_vec(); + let extras = agents_states + .keys() + .filter(|id| !ids.iter().any(|seen| seen == *id)) + .cloned() + .collect::>(); + ids.extend(extras); + ids.sort(); + ids.dedup(); + if ids.is_empty() { + return vec!["No agents completed yet".to_string()]; + } + ids.into_iter() + .map(|id| { + let status = agents_states.get(&id).map_or_else( + || "Error - agent state unavailable".to_string(), + status_summary_text, + ); + format!("{id}: {status}") + }) + .collect() +} + +fn status_summary_text(status: &CollabAgentState) -> String { + match status.status { + CollabAgentStatus::PendingInit => "Pending init".to_string(), + CollabAgentStatus::Running => "Running".to_string(), + CollabAgentStatus::Interrupted => "Interrupted".to_string(), + CollabAgentStatus::Completed => status.message.as_deref().map_or_else( + || "Completed".to_string(), + |message| { + format!( + "Completed - {}", + compact_text(message, COLLAB_AGENT_RESPONSE_PREVIEW_GRAPHEMES) + ) + }, + ), + CollabAgentStatus::Errored => format!( + "Error - {}", + compact_text( + status.message.as_deref().unwrap_or("Agent errored"), + COLLAB_AGENT_ERROR_PREVIEW_GRAPHEMES + ) + ), + CollabAgentStatus::Shutdown => "Shutdown".to_string(), + CollabAgentStatus::NotFound => "Not found".to_string(), + } +} + +fn prompt_line(prompt: &str) -> Option { + let prompt = non_empty(Some(prompt))?; + Some(compact_text(prompt, COLLAB_PROMPT_PREVIEW_GRAPHEMES)) +} + +fn compact_text(value: &str, max_graphemes: usize) -> String { + let value = value.lines().next().unwrap_or(value); + let mut text = value.chars().take(max_graphemes).collect::(); + if value.chars().count() > max_graphemes { + text.push_str("..."); + } + text +} + +fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::{CollabAgentTool, ThreadItem}; + + fn activity(kind: SubAgentActivityKind) -> ThreadItem { + ThreadItem::SubAgentActivity { + id: "activity-1".to_string(), + metadata: None, + kind, + agent_thread_id: "agent-1".to_string(), + agent_path: "/root/child".to_string(), + } + } + + #[test] + fn interacted_sub_agent_activity_does_not_change_liveness() { + assert_eq!( + sub_agent_activity_display(&activity(SubAgentActivityKind::Interacted)), + None + ); + } + + #[test] + fn interrupted_sub_agent_activity_stops_running_liveness() { + assert_eq!( + sub_agent_activity_display(&activity(SubAgentActivityKind::Interrupted)), + Some(SubAgentActivityDisplay { + thread_id: "agent-1".to_string(), + agent_path: "/root/child".to_string(), + is_running_hint: false, + }) + ); + } + + #[test] + fn tool_call_history_cells_cover_collaboration_lifecycle() { + let mut agents_states = HashMap::new(); + agents_states.insert( + "agent-1".to_string(), + CollabAgentState { + status: CollabAgentStatus::Completed, + message: Some("39916800".to_string()), + }, + ); + let item = ThreadItem::CollabAgentToolCall { + id: "call-wait".to_string(), + metadata: None, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-1".to_string(), + receiver_thread_ids: vec!["agent-1".to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }; + let cell = tool_call_history_cell(&item).expect("wait history cell"); + assert_eq!(cell.title, "Finished waiting"); + assert_eq!(cell.details, vec!["agent-1: Completed - 39916800"]); + } + + #[test] + fn spawn_request_summary_preserves_model_and_effort() { + let effort = ReasoningEffort::new("high").expect("effort"); + let item = ThreadItem::CollabAgentToolCall { + id: "call-spawn".to_string(), + metadata: None, + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-1".to_string(), + receiver_thread_ids: vec!["agent-1".to_string()], + prompt: None, + model: Some("gpt-5".to_string()), + reasoning_effort: Some(effort.clone()), + agents_states: HashMap::new(), + }; + assert_eq!( + spawn_request_summary(&item), + Some(SpawnRequestSummary { + model: "gpt-5".to_string(), + reasoning_effort: effort, + }) + ); + } +} diff --git a/lime-rs/crates/tui/src/pager_overlay.rs b/lime-rs/crates/tui/src/pager_overlay.rs index 16bec5db7..ee28fecc3 100644 --- a/lime-rs/crates/tui/src/pager_overlay.rs +++ b/lime-rs/crates/tui/src/pager_overlay.rs @@ -1,11 +1,11 @@ use std::cell::Cell; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Clear, Paragraph}; +use ratatui::Frame; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; @@ -261,8 +261,8 @@ impl PagerOverlay { mod tests { use super::*; use crossterm::event::{KeyEvent, KeyModifiers}; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::Terminal; fn key(code: KeyCode) -> Event { Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) diff --git a/lime-rs/crates/tui/src/pending_input_preview.rs b/lime-rs/crates/tui/src/pending_input_preview.rs index 36e4dd60e..6888e31b4 100644 --- a/lime-rs/crates/tui/src/pending_input_preview.rs +++ b/lime-rs/crates/tui/src/pending_input_preview.rs @@ -1,13 +1,13 @@ use app_server_protocol::protocol::v2::{QueuedSubmission, UserInput}; -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; +use ratatui::Frame; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; -use crate::terminal_hyperlinks::{HyperlinkLine, wrap_hyperlink_line}; +use crate::terminal_hyperlinks::{wrap_hyperlink_line, HyperlinkLine}; const MAX_VISIBLE_SUBMISSIONS: usize = 2; const MAX_LINES_PER_SUBMISSION: usize = 2; @@ -270,13 +270,11 @@ mod tests { assert!(can_restore_submission(&local)); assert!(!can_restore_submission(&remote)); assert!(!can_restore_submission(&structured)); - assert!( - !preview_lines(&[remote], 80, Locale::EnUs) - .iter() - .map(line_text) - .collect::() - .contains("Alt+Up") - ); + assert!(!preview_lines(&[remote], 80, Locale::EnUs) + .iter() + .map(line_text) + .collect::() + .contains("Alt+Up")); } #[test] diff --git a/lime-rs/crates/tui/src/projection.rs b/lime-rs/crates/tui/src/projection.rs index 118cbcaa1..d7c58c3b6 100644 --- a/lime-rs/crates/tui/src/projection.rs +++ b/lime-rs/crates/tui/src/projection.rs @@ -4,6 +4,8 @@ use app_server_protocol::protocol::v2::{ UserInput, }; +use crate::multi_agents; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum EntryKind { User, @@ -431,39 +433,43 @@ fn project_item(item: &ThreadItem, streaming: bool) -> Option { Some(dynamic_entry_status(*status)), dynamic_summary(content_items.as_deref(), *success, *duration_ms), ), - ThreadItem::CollabAgentToolCall { - id, - tool, - status, - prompt, - model, - reasoning_effort, - agents_states, - .. - } => ( - id.clone(), - EntryKind::MultiAgent, - format!("{tool:?}"), - Some(collab_entry_status(*status)), - collab_summary( - agents_states, - prompt.as_deref(), - model.as_deref(), - reasoning_effort.as_ref(), - ), - ), - ThreadItem::SubAgentActivity { + item @ ThreadItem::CollabAgentToolCall { + id, tool, status, .. + } => { + let text = multi_agents::tool_call_history_cell(item) + .map(|cell| cell.title) + .unwrap_or_else(|| format!("{tool:?}")); + ( + id.clone(), + EntryKind::MultiAgent, + text, + Some(collab_entry_status(*status)), + multi_agents::collab_summary_for_item(item), + ) + } + item @ ThreadItem::SubAgentActivity { id, kind, agent_path, .. - } => ( - id.clone(), - EntryKind::MultiAgent, - format!("{agent_path} [{kind:?}]"), - sub_agent_status(*kind), - Vec::new(), - ), + } => { + let status = multi_agents::sub_agent_activity_display(item) + .map(|activity| { + if activity.is_running_hint { + EntryStatus::Running + } else { + EntryStatus::Interrupted + } + }) + .or(Some(EntryStatus::Running)); + ( + id.clone(), + EntryKind::MultiAgent, + multi_agents::sub_agent_activity_summary(*kind, agent_path), + status, + Vec::new(), + ) + } ThreadItem::WebSearch(item) => ( item.id.clone(), EntryKind::Tool, @@ -619,64 +625,6 @@ fn dynamic_summary( details } -fn collab_summary( - agents_states: &std::collections::HashMap< - String, - app_server_protocol::protocol::v2::CollabAgentState, - >, - prompt: Option<&str>, - model: Option<&str>, - reasoning_effort: Option<&app_server_protocol::protocol::v2::ReasoningEffort>, -) -> Vec { - let mut details = vec![format!("agents: {}", agents_states.len())]; - let mut counts = std::collections::BTreeMap::<&'static str, usize>::new(); - for state in agents_states.values() { - *counts - .entry(collab_agent_status_label(state.status)) - .or_default() += 1; - } - details.extend( - counts - .into_iter() - .map(|(label, count)| format!("{label}: {count}")), - ); - if let Some(model) = model.filter(|model| !model.trim().is_empty()) { - details.push(format!("model: {}", compact_text(model))); - } - if let Some(reasoning_effort) = reasoning_effort { - details.push(format!("effort: {}", reasoning_effort.as_str())); - } - if let Some(prompt) = prompt.filter(|prompt| !prompt.trim().is_empty()) { - details.push(format!("prompt: {}", compact_text(prompt))); - } - details -} - -fn sub_agent_status( - kind: app_server_protocol::protocol::v2::SubAgentActivityKind, -) -> Option { - use app_server_protocol::protocol::v2::SubAgentActivityKind; - Some(match kind { - SubAgentActivityKind::Started | SubAgentActivityKind::Interacted => EntryStatus::Running, - SubAgentActivityKind::Interrupted => EntryStatus::Interrupted, - }) -} - -fn collab_agent_status_label( - status: app_server_protocol::protocol::v2::CollabAgentStatus, -) -> &'static str { - use app_server_protocol::protocol::v2::CollabAgentStatus; - match status { - CollabAgentStatus::PendingInit => "pending", - CollabAgentStatus::Running => "running", - CollabAgentStatus::Interrupted => "interrupted", - CollabAgentStatus::Completed => "completed", - CollabAgentStatus::Errored => "errored", - CollabAgentStatus::Shutdown => "shutdown", - CollabAgentStatus::NotFound => "not-found", - } -} - fn compact_text(value: &str) -> String { let value = value.lines().next().unwrap_or(value); let mut text = value.chars().take(160).collect::(); diff --git a/lime-rs/crates/tui/src/projection_tests.rs b/lime-rs/crates/tui/src/projection_tests.rs index 4bf80da46..f289c9e23 100644 --- a/lime-rs/crates/tui/src/projection_tests.rs +++ b/lime-rs/crates/tui/src/projection_tests.rs @@ -472,6 +472,79 @@ fn collab_agent_summary_keeps_requested_model_effort_and_prompt() { ); } +#[test] +fn collab_wait_projection_keeps_per_agent_results() { + let mut agents_states = HashMap::new(); + agents_states.insert( + "agent-1".to_string(), + CollabAgentState { + status: CollabAgentStatus::Completed, + message: Some("answer".to_string()), + }, + ); + agents_states.insert( + "agent-2".to_string(), + CollabAgentState { + status: CollabAgentStatus::Errored, + message: Some("timeout".to_string()), + }, + ); + + let collab = project_item( + &ThreadItem::CollabAgentToolCall { + id: "collab-wait".to_string(), + metadata: None, + tool: CollabAgentTool::Wait, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-1".to_string(), + receiver_thread_ids: vec!["agent-2".to_string(), "agent-1".to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states, + }, + false, + ) + .expect("collab projection"); + + assert!(collab + .summary + .iter() + .any(|detail| detail == "agent-1: Completed - answer")); + assert!(collab + .summary + .iter() + .any(|detail| detail == "agent-2: Error - timeout")); +} + +#[test] +fn collab_resume_projection_keeps_resume_result() { + let collab = project_item( + &ThreadItem::CollabAgentToolCall { + id: "collab-resume".to_string(), + metadata: None, + tool: CollabAgentTool::ResumeAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-1".to_string(), + receiver_thread_ids: vec!["agent-1".to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::from([( + "agent-1".to_string(), + CollabAgentState { + status: CollabAgentStatus::Interrupted, + message: None, + }, + )]), + }, + false, + ) + .expect("collab projection"); + + assert!(collab.summary.iter().any(|detail| detail == "Interrupted")); +} + #[test] fn turn_terminal_status_settles_in_progress_items() { let mut projection = ConversationProjection::default(); diff --git a/lime-rs/crates/tui/src/reconnect.rs b/lime-rs/crates/tui/src/reconnect.rs index 0c173ea5e..94026472c 100644 --- a/lime-rs/crates/tui/src/reconnect.rs +++ b/lime-rs/crates/tui/src/reconnect.rs @@ -1,82 +1,7 @@ -use std::time::Duration; +//! Compatibility export for callers that still import the old top-level path. +//! +//! The implementation owner is `app::reconnect`, matching Codex. New call sites must import from +//! that module; this file only delegates while the public crate surface is migrated. -use crate::app::App; -use crate::app_server_session::AppServerSession; -use crate::runtime::{TuiOptions, connect_session}; -use anyhow::{Result, bail}; - -const RECONNECT_DELAYS: [Duration; 4] = [ - Duration::ZERO, - Duration::from_millis(100), - Duration::from_millis(300), - Duration::from_secs(1), -]; - -pub(crate) async fn reconnect_session( - options: &TuiOptions, - app: &mut App, - thread_id: &str, - model: Option, - model_provider: Option, - effort: Option, - permissions: Option, -) -> Result { - let mut last_error = None; - for delay in RECONNECT_DELAYS { - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - let mut candidate = match connect_session(options).await { - Ok(session) => session, - Err(error) => { - last_error = Some(error); - continue; - } - }; - match candidate.resume_thread(thread_id.to_string()).await { - Ok(response) => { - let permission_profiles = candidate - .list_permission_profiles(Some(response.cwd.clone())) - .await; - let permission_profiles = match permission_profiles { - Ok(response) => response, - Err(error) => { - let _ = candidate.shutdown().await; - last_error = Some(error); - continue; - } - }; - if let Err(error) = candidate - .update_settings( - model.clone(), - model_provider.clone(), - effort.clone(), - permissions.clone(), - ) - .await - { - let _ = candidate.shutdown().await; - last_error = Some(error); - continue; - } - app.hydrate_thread(response.thread); - app.set_permission_profiles( - permission_profiles - .data - .into_iter() - .filter(|profile| profile.allowed) - .map(|profile| profile.id), - ); - return Ok(candidate); - } - Err(error) => { - let _ = candidate.shutdown().await; - last_error = Some(error); - } - } - } - let error = last_error - .map(|error| format!("{error:#}")) - .unwrap_or_else(|| "unknown reconnect failure".to_string()); - bail!("failed to reconnect App Server and resume thread {thread_id}: {error}") -} +#[allow(unused_imports)] +pub(crate) use crate::app::reconnect::{reconnect_session, ReconnectedSession}; diff --git a/lime-rs/crates/tui/src/resume_picker.rs b/lime-rs/crates/tui/src/resume_picker.rs index 191aa82eb..a8599f05c 100644 --- a/lime-rs/crates/tui/src/resume_picker.rs +++ b/lime-rs/crates/tui/src/resume_picker.rs @@ -1,29 +1,32 @@ use anyhow::{Context, Result}; use app_server_client::RequestHandle; use app_server_protocol::protocol::v2::{ - METHOD_THREAD_ARCHIVE, METHOD_THREAD_UNARCHIVE, SortDirection, Thread, ThreadArchiveParams, - ThreadHistoryMode, ThreadListCwdFilter, ThreadListParams, ThreadListResponse, ThreadSortKey, - ThreadStatus, ThreadUnarchiveParams, ThreadUnarchiveResponse, + SortDirection, Thread, ThreadArchiveParams, ThreadHistoryMode, ThreadListCwdFilter, + ThreadListParams, ThreadListResponse, ThreadSortKey, ThreadStatus, ThreadUnarchiveParams, + ThreadUnarchiveResponse, METHOD_THREAD_ARCHIVE, METHOD_THREAD_UNARCHIVE, }; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use futures::StreamExt; -use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use tokio::sync::mpsc; use crate::clipboard_paste::normalize_pasted_search_query; +use crate::entry; use crate::locale::Locale; +use crate::pager_overlay::{PagerAction, PagerOverlay}; use crate::projection::{EntryKind, TranscriptEntry}; -use crate::runtime::{TuiOptions, connect_session}; +use crate::runtime::{connect_session, TuiOptions}; +use crate::terminal_hyperlinks::HyperlinkLine; use crate::text_formatting::center_truncate_path; -use crate::tui::{TerminalGuard, TuiEvent}; +use crate::tui::{Tui, TuiEvent}; use crate::width::display_width; -use crate::wrapping::{RtOptions, adaptive_wrap_line}; +use crate::wrapping::{adaptive_wrap_line, RtOptions}; mod archive; mod page_loading; @@ -34,7 +37,6 @@ use page_loading::{PageCursor, PageLoadMode, PaginationState}; mod transcript_preview; const MAX_THREADS: u32 = 100; -const MAX_PAGES: usize = 16; #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] @@ -132,72 +134,42 @@ pub(crate) async fn run_fork_picker_with_app_server( Ok(Some(response.thread.id)) } -async fn load_threads_with_handle( +#[derive(Debug)] +pub(crate) struct ThreadPage { + threads: Vec, + next_cursor: Option, +} + +async fn load_thread_page_with_handle( request_handle: RequestHandle, status: SessionStatus, filter_cwd: Option<&std::path::Path>, query: &str, sort_key: ThreadSortKey, -) -> Result> { - let mut pagination = PaginationState::new(); - let mut cursor = None; - let mut data = Vec::new(); - let mut seen_cursors = std::collections::HashSet::new(); - let mut seen_thread_ids = std::collections::HashSet::new(); - - for request_token in 0..MAX_PAGES { - if request_token == 0 { - pagination.reset(); - } - // Lime's current App Server owns the canonical index; do not switch - // to Codex's private local state DB mode for later pages. - let mode = PageLoadMode::StoreDefault; - pagination.start_load(request_token, None, mode); - let page: ThreadListResponse = request_handle - .request( - app_server_protocol::protocol::v2::METHOD_THREAD_LIST, - ThreadListParams { - cursor: cursor.clone(), - limit: Some(MAX_THREADS), - sort_key: Some(sort_key), - sort_direction: Some(SortDirection::Desc), - archived: Some(status == SessionStatus::Archived), - cwd: filter_cwd - .map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())), - search_term: (!query.trim().is_empty()).then(|| query.trim().to_string()), - ..ThreadListParams::default() - }, - ) - .await?; - pagination - .finish_load(request_token) - .ok_or_else(|| anyhow::anyhow!("thread list response became stale"))?; - let next_cursor = page.next_cursor.clone(); - let page_len = page.data.len(); - data.extend( - page.data - .into_iter() - .filter(|thread| seen_thread_ids.insert(thread.id.clone())), - ); - pagination.complete_page( - next_cursor.clone().map(PageCursor::AppServer), - page_len, - false, - ); - let Some((PageCursor::AppServer(next), _mode)) = pagination.next_page() else { - return Ok(filter_threads(data, query)); - }; - if !seen_cursors.insert(next.clone()) { - return Err(anyhow::anyhow!( - "thread list pagination repeated cursor {next}" - )); - } - cursor = Some(next); - } - - Err(anyhow::anyhow!( - "thread list pagination exceeded {MAX_PAGES} pages" - )) + cursor: Option, +) -> Result { + // Lime's current App Server owns the canonical index; do not switch to + // Codex's private local state DB mode for later pages. + let page: ThreadListResponse = request_handle + .request( + app_server_protocol::protocol::v2::METHOD_THREAD_LIST, + ThreadListParams { + cursor, + limit: Some(MAX_THREADS), + sort_key: Some(sort_key), + sort_direction: Some(SortDirection::Desc), + archived: Some(status == SessionStatus::Archived), + cwd: filter_cwd + .map(|cwd| ThreadListCwdFilter::One(cwd.to_string_lossy().into_owned())), + search_term: (!query.trim().is_empty()).then(|| query.trim().to_string()), + ..ThreadListParams::default() + }, + ) + .await?; + Ok(ThreadPage { + threads: filter_threads(page.data, query), + next_cursor: page.next_cursor, + }) } fn filter_threads(mut threads: Vec, query: &str) -> Vec { @@ -227,7 +199,7 @@ fn filter_threads(mut threads: Vec, query: &str) -> Vec { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PickerAction { +pub(crate) enum PickerAction { None, Reload, MoveUp, @@ -240,13 +212,14 @@ enum PickerAction { ToggleSort, ToggleDensity, ToggleExpanded, + OpenTranscript, Cancel, } -enum PickerLoadEvent { +pub(crate) enum PickerLoadEvent { Threads { token: usize, - result: Result>, + result: Result, }, Preview { thread_id: String, @@ -267,7 +240,7 @@ enum PickerLoadEvent { } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -enum SessionStatus { +pub(crate) enum SessionStatus { #[default] Active, Archived, @@ -287,6 +260,21 @@ enum SessionTranscriptState { Failed, } +#[derive(Debug)] +struct PickerTranscriptPager { + thread_id: String, + overlay: PagerOverlay, +} + +impl PickerTranscriptPager { + fn new(thread_id: String, locale: Locale) -> Self { + Self { + thread_id, + overlay: PagerOverlay::transcript(locale), + } + } +} + impl SessionListDensity { fn toggle(self) -> Self { match self { @@ -297,9 +285,9 @@ impl SessionListDensity { } #[derive(Debug)] -struct PickerState { +pub(crate) struct PickerState { action: SessionPickerAction, - threads: Vec, + pub(crate) threads: Vec, selected: usize, query: String, status: SessionStatus, @@ -311,14 +299,17 @@ struct PickerState { preview_loading: HashSet, expanded_thread_id: Option, transcripts: HashMap, + transcript_pager: Option, + pagination: PaginationState, + seen_cursors: HashSet, archive_state: archive::ArchiveState, status_message: Option, loading: bool, - load_token: usize, + pub(crate) load_token: usize, } impl PickerState { - fn new( + pub(crate) fn new( threads: Vec, action: SessionPickerAction, status: SessionStatus, @@ -339,6 +330,9 @@ impl PickerState { preview_loading: HashSet::new(), expanded_thread_id: None, transcripts: HashMap::new(), + transcript_pager: None, + pagination: PaginationState::new(), + seen_cursors: HashSet::new(), archive_state: archive::ArchiveState::Idle, status_message: None, loading: false, @@ -346,13 +340,13 @@ impl PickerState { } } - fn selected_thread_id(&self) -> Option<&str> { + pub(crate) fn selected_thread_id(&self) -> Option<&str> { self.threads .get(self.selected) .map(|thread| thread.id.as_str()) } - fn set_transcript_preview( + pub(crate) fn set_transcript_preview( &mut self, thread_id: String, preview: Vec, @@ -370,7 +364,7 @@ impl PickerState { self.preview_loading.insert(thread_id.into()); } - fn toggle_selected_expansion(&mut self) -> Option { + pub(crate) fn toggle_selected_expansion(&mut self) -> Option { let thread_id = self.selected_thread_id()?.to_string(); if self.expanded_thread_id.as_deref() == Some(thread_id.as_str()) { self.expanded_thread_id = None; @@ -388,7 +382,71 @@ impl PickerState { None } - fn set_transcript(&mut self, thread_id: String, result: std::io::Result>) { + pub(crate) fn open_transcript_pager(&mut self, locale: Locale) -> Option { + let thread_id = self.selected_thread_id()?.to_string(); + let should_load = if self.transcripts.contains_key(&thread_id) { + false + } else { + self.transcripts + .insert(thread_id.clone(), SessionTranscriptState::Loading); + true + }; + self.transcript_pager = Some(PickerTranscriptPager::new(thread_id.clone(), locale)); + should_load.then_some(thread_id) + } + + fn transcript_lines(&self, thread_id: &str, width: u16, locale: Locale) -> Vec { + let Some(state) = self.transcripts.get(thread_id) else { + return vec![HyperlinkLine::new(Line::styled( + locale.resume_transcript_loading(), + Style::default().fg(Color::DarkGray), + ))]; + }; + match state { + SessionTranscriptState::Loading => vec![HyperlinkLine::new(Line::styled( + locale.resume_transcript_loading(), + Style::default().fg(Color::DarkGray), + ))], + SessionTranscriptState::Failed => vec![HyperlinkLine::new(Line::styled( + locale.resume_transcript_failed(), + Style::default().fg(Color::Red), + ))], + SessionTranscriptState::Loaded(entries) if entries.is_empty() => { + vec![HyperlinkLine::new(Line::styled( + locale.resume_transcript_empty(), + Style::default().fg(Color::DarkGray), + ))] + } + SessionTranscriptState::Loaded(entries) => { + let content_width = Some(usize::from(width.saturating_sub(2).max(1))); + let cwd = self + .threads + .iter() + .find(|thread| thread.id == thread_id) + .map(|thread| thread.cwd.as_path()); + let cwd = cwd.unwrap_or_else(|| std::path::Path::new("")); + let mut lines = Vec::new(); + for entry in entries { + if !lines.is_empty() { + lines.push(HyperlinkLine::default()); + } + lines.extend(entry::hyperlink_lines_with_locale( + entry, + locale, + content_width, + cwd, + )); + } + lines + } + } + } + + pub(crate) fn set_transcript( + &mut self, + thread_id: String, + result: std::io::Result>, + ) { self.transcripts.insert( thread_id, match result { @@ -402,15 +460,62 @@ impl PickerState { self.load_token = self.load_token.wrapping_add(1); self.loading = true; self.status_message = None; + self.pagination + .start_load(self.load_token, None, PageLoadMode::StoreDefault); self.load_token } + #[cfg(test)] fn apply_threads(&mut self, token: usize, threads: Vec) { - if token != self.load_token { + self.apply_thread_page( + token, + ThreadPage { + threads, + next_cursor: None, + }, + ); + } + + pub(crate) fn apply_thread_page(&mut self, token: usize, page: ThreadPage) { + if token != self.load_token || self.pagination.finish_load(token).is_none() { return; } self.loading = false; - self.set_threads(threads); + let page_len = page.threads.len(); + let next_cursor = page.next_cursor; + let mut seen_thread_ids = self + .threads + .iter() + .map(|thread| thread.id.clone()) + .collect::>(); + self.threads.extend( + page.threads + .into_iter() + .filter(|thread| seen_thread_ids.insert(thread.id.clone())), + ); + self.pagination + .complete_page(next_cursor.map(PageCursor::AppServer), page_len, false); + self.selected = self.selected.min(self.threads.len().saturating_sub(1)); + } + + pub(crate) fn fail_thread_load(&mut self, token: usize, error: anyhow::Error) { + if token != self.load_token { + return; + } + let _ = self.pagination.finish_load(token); + self.loading = false; + self.status_message = Some(error.to_string()); + } + + pub(crate) fn should_load_more(&self) -> bool { + !self.pagination.is_loading() + && self.pagination.next_page().is_some() + && !self.threads.is_empty() + && self.selected + 1 >= self.threads.len() + } + + pub(crate) fn has_more_pages(&self) -> bool { + self.pagination.next_page().is_some() } fn transcript_preview_text(&self, thread_id: &str) -> Option { @@ -430,13 +535,14 @@ impl PickerState { }) } - fn handle_event(&mut self, event: Event) -> PickerAction { + pub(crate) fn handle_event(&mut self, event: Event) -> PickerAction { if let Event::Paste(text) = event { if let Some(text) = normalize_pasted_search_query(&text) { if !self.query.is_empty() && !self.query.ends_with(char::is_whitespace) { self.query.push(' '); } self.query.push_str(&text); + self.invalidate_thread_list(); } return PickerAction::Reload; } @@ -465,9 +571,13 @@ impl PickerState { KeyCode::Char('r') => return PickerAction::ToggleSort, KeyCode::Char('o') => return PickerAction::ToggleDensity, KeyCode::Char('e') => return PickerAction::ToggleExpanded, + KeyCode::Char('t') => return PickerAction::OpenTranscript, _ => {} } } + if key.modifiers.is_empty() && key.code == KeyCode::Char('\u{0014}') { + return PickerAction::OpenTranscript; + } if key.modifiers.is_empty() && key.code == KeyCode::Char('\u{0005}') { return PickerAction::ToggleExpanded; } @@ -476,32 +586,55 @@ impl PickerState { self.selected = self.selected.saturating_sub(1); PickerAction::MoveUp } + KeyCode::PageUp => { + self.selected = self.selected.saturating_sub(10); + PickerAction::MoveUp + } KeyCode::Down | KeyCode::Char('j') => { if !self.threads.is_empty() { self.selected = (self.selected + 1).min(self.threads.len() - 1); } PickerAction::MoveDown } + KeyCode::PageDown => { + if !self.threads.is_empty() { + self.selected = (self.selected + 10).min(self.threads.len() - 1); + } + PickerAction::MoveDown + } + KeyCode::Home => { + self.selected = 0; + PickerAction::MoveUp + } + KeyCode::End => { + if !self.threads.is_empty() { + self.selected = self.threads.len() - 1; + } + PickerAction::MoveDown + } KeyCode::Enter if self.status == SessionStatus::Archived => PickerAction::Restore, KeyCode::Enter => PickerAction::Select, KeyCode::Backspace => { self.query.pop(); + self.invalidate_thread_list(); PickerAction::Reload } KeyCode::Esc if self.query.is_empty() => PickerAction::Cancel, KeyCode::Esc => { self.query.clear(); + self.invalidate_thread_list(); PickerAction::Reload } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::ALT) => { self.query.push(c); + self.invalidate_thread_list(); PickerAction::Reload } _ => PickerAction::None, } } - fn toggle_status(&mut self) { + pub(crate) fn toggle_status(&mut self) { self.status = match self.status { SessionStatus::Active => SessionStatus::Archived, SessionStatus::Archived => SessionStatus::Active, @@ -511,18 +644,24 @@ impl PickerState { self.preview_loading.clear(); self.expanded_thread_id = None; self.transcripts.clear(); + self.transcript_pager = None; + self.pagination.reset(); + self.seen_cursors.clear(); } - fn toggle_filter(&mut self) { + pub(crate) fn toggle_filter(&mut self) { self.show_all = !self.show_all; self.selected = 0; self.transcript_previews.clear(); self.preview_loading.clear(); self.expanded_thread_id = None; self.transcripts.clear(); + self.transcript_pager = None; + self.pagination.reset(); + self.seen_cursors.clear(); } - fn toggle_sort(&mut self) { + pub(crate) fn toggle_sort(&mut self) { self.sort_key = match self.sort_key { ThreadSortKey::UpdatedAt => ThreadSortKey::CreatedAt, ThreadSortKey::CreatedAt @@ -534,8 +673,12 @@ impl PickerState { self.preview_loading.clear(); self.expanded_thread_id = None; self.transcripts.clear(); + self.transcript_pager = None; + self.pagination.reset(); + self.seen_cursors.clear(); } + #[cfg(test)] fn set_threads(&mut self, threads: Vec) { self.threads = filter_threads(threads, &self.query); self.selected = self.selected.min(self.threads.len().saturating_sub(1)); @@ -543,14 +686,61 @@ impl PickerState { self.preview_loading.clear(); self.expanded_thread_id = None; self.transcripts.clear(); + self.transcript_pager = None; + self.pagination.reset(); + self.seen_cursors.clear(); + } + + pub(crate) fn invalidate_thread_list(&mut self) { + self.pagination.reset(); + self.seen_cursors.clear(); + } + + pub(crate) fn toggle_density(&mut self) { + self.density = self.density.toggle(); + } + + pub(crate) fn transcript_pager_is_open(&self) -> bool { + self.transcript_pager.is_some() + } + + pub(crate) fn handle_transcript_pager_event(&mut self, event: &Event) { + if let Some(pager) = self.transcript_pager.as_mut() { + if pager.overlay.handle_event(event) == PagerAction::Close { + self.transcript_pager = None; + } + } } } -fn spawn_thread_load( +pub(crate) fn spawn_thread_load( request_handle: RequestHandle, sender: &mpsc::UnboundedSender, picker: &mut PickerState, ) { + let cursor = picker + .pagination + .next_cursor + .as_ref() + .map(|cursor| match cursor { + PageCursor::AppServer(cursor) => cursor.clone(), + }); + if cursor.is_none() { + picker.threads.clear(); + picker.selected = 0; + picker.transcript_previews.clear(); + picker.preview_loading.clear(); + picker.expanded_thread_id = None; + picker.transcripts.clear(); + picker.transcript_pager = None; + picker.seen_cursors.clear(); + } else if let Some(cursor) = cursor.as_deref() { + if !picker.seen_cursors.insert(cursor.to_string()) { + picker.status_message = + Some(format!("thread list pagination repeated cursor {cursor}")); + return; + } + } let token = picker.begin_load(); let status = picker.status; let cwd = (!picker.show_all) @@ -560,14 +750,20 @@ fn spawn_thread_load( let sort_key = picker.sort_key; let sender = sender.clone(); tokio::spawn(async move { - let result = - load_threads_with_handle(request_handle, status, cwd.as_deref(), &query, sort_key) - .await; + let result = load_thread_page_with_handle( + request_handle, + status, + cwd.as_deref(), + &query, + sort_key, + cursor, + ) + .await; let _ = sender.send(PickerLoadEvent::Threads { token, result }); }); } -fn spawn_preview_load( +pub(crate) fn spawn_preview_load( request_handle: RequestHandle, sender: &mpsc::UnboundedSender, picker: &mut PickerState, @@ -584,7 +780,7 @@ fn spawn_preview_load( }); } -fn spawn_transcript_load( +pub(crate) fn spawn_transcript_load( request_handle: RequestHandle, sender: &mpsc::UnboundedSender, thread_id: String, @@ -625,7 +821,7 @@ async fn load_transcript_preview_with_handle( transcript_preview::preview_from_entries(preview_entries) } -fn spawn_archive_request( +pub(crate) fn spawn_archive_request( request_handle: RequestHandle, sender: &mpsc::UnboundedSender, thread_id: String, @@ -646,7 +842,7 @@ fn spawn_archive_request( }); } -fn spawn_unarchive_request( +pub(crate) fn spawn_unarchive_request( request_handle: RequestHandle, sender: &mpsc::UnboundedSender, thread_id: String, @@ -692,7 +888,7 @@ async fn run_session_picker_with_action( ); spawn_thread_load(request_handle.clone(), &load_tx, &mut picker); let locale = Locale::resolve(options.locale.as_deref()); - let mut terminal = match TerminalGuard::enter().context("failed to initialize terminal") { + let mut terminal = match Tui::enter().context("failed to initialize terminal") { Ok(terminal) => terminal, Err(error) => { let _ = session.shutdown().await; @@ -713,10 +909,15 @@ async fn run_session_picker_with_action( let Some(load_event) = load_event else { break None; }; match load_event { PickerLoadEvent::Threads { token, result } => match result { - Ok(threads) => picker.apply_threads(token, threads), + Ok(page) => { + picker.apply_thread_page(token, page); + if picker.threads.is_empty() && picker.pagination.next_page().is_some() + { + spawn_thread_load(request_handle.clone(), &load_tx, &mut picker); + } + } Err(error) if token == picker.load_token => { - picker.loading = false; - picker.status_message = Some(error.to_string()); + picker.fail_thread_load(token, error); } Err(_) => {} }, @@ -747,6 +948,12 @@ async fn run_session_picker_with_action( TuiEvent::FocusLost => Event::FocusLost, TuiEvent::Draw | TuiEvent::Resume => continue, }; + if let Some(pager) = picker.transcript_pager.as_mut() { + if pager.overlay.handle_event(&event) == PagerAction::Close { + picker.transcript_pager = None; + } + continue; + } let action = picker.handle_event(event); match action { PickerAction::Select => break picker.selected_thread_id().map(ToOwned::to_owned), @@ -778,7 +985,15 @@ async fn run_session_picker_with_action( spawn_transcript_load(request_handle.clone(), &load_tx, thread_id); } } + PickerAction::OpenTranscript => { + if let Some(thread_id) = picker.open_transcript_pager(locale) { + spawn_transcript_load(request_handle.clone(), &load_tx, thread_id); + } + } PickerAction::Cancel => break None, + PickerAction::MoveDown if picker.should_load_more() => { + spawn_thread_load(request_handle.clone(), &load_tx, &mut picker); + } PickerAction::None | PickerAction::MoveUp | PickerAction::MoveDown => {} } } @@ -796,8 +1011,13 @@ fn render(frame: &mut Frame<'_>, picker: &PickerState) { render_with_locale(frame, picker, Locale::default()); } -fn render_with_locale(frame: &mut Frame<'_>, picker: &PickerState, locale: Locale) { +pub(crate) fn render_with_locale(frame: &mut Frame<'_>, picker: &PickerState, locale: Locale) { let area = frame.area(); + if let Some(pager) = picker.transcript_pager.as_ref() { + let lines = picker.transcript_lines(&pager.thread_id, area.width, locale); + pager.overlay.render(frame, area, locale, &lines); + return; + } let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ @@ -911,13 +1131,14 @@ fn render_with_locale(frame: &mut Frame<'_>, picker: &PickerState, locale: Local message.to_string() } else { format!( - "{} | {} {} | {}", + "{} | {} {} | {} | {}", locale.resume_enter_hint( matches!(picker.action, SessionPickerAction::Fork), picker.status == SessionStatus::Archived, ), locale.resume_controls_hint(), locale.resume_expand_hint(), + locale.resume_transcript_hint(), locale.resume_density_label(picker.density == SessionListDensity::Dense), ) }; @@ -1105,8 +1326,8 @@ fn truncate_display(text: &str, max_width: usize) -> String { mod tests { use super::*; use app_server_protocol::protocol::v2::{SessionSource, ThreadActiveFlag, ThreadHistoryMode}; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::Terminal; use std::path::PathBuf; fn thread(id: &str, preview: &str, ephemeral: bool) -> Thread { @@ -1143,6 +1364,18 @@ mod tests { } } + fn buffer_text(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + #[test] fn picker_filters_ephemeral_threads_and_navigates() { let mut picker = PickerState::new( @@ -1186,14 +1419,11 @@ mod tests { ); let mut terminal = Terminal::new(TestBackend::new(24, 8)).expect("terminal"); terminal.draw(|frame| render(frame, &picker)).expect("draw"); - assert!( - terminal - .backend() - .buffer() - .content() - .iter() - .all(|cell| cell.symbol().chars().count() <= 1) - ); + assert!(terminal.backend().buffer().content().iter().all(|cell| cell + .symbol() + .chars() + .count() + <= 1)); } #[test] @@ -1282,6 +1512,107 @@ mod tests { assert_eq!(picker.density, SessionListDensity::Dense); } + #[test] + fn ctrl_t_opens_a_shared_transcript_pager_and_starts_loading_when_needed() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + let key = Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('t'), + KeyModifiers::CONTROL, + )); + assert_eq!(picker.handle_event(key), PickerAction::OpenTranscript); + assert_eq!( + picker.open_transcript_pager(Locale::EnUs), + Some("one".to_string()) + ); + assert!(picker.transcript_pager.is_some()); + assert!(matches!( + picker.transcripts.get("one"), + Some(SessionTranscriptState::Loading) + )); + + let mut terminal = Terminal::new(TestBackend::new(40, 8)).expect("terminal"); + terminal + .draw(|frame| render_with_locale(frame, &picker, Locale::EnUs)) + .expect("draw"); + assert!(buffer_text(&terminal).contains("Loading transcript")); + } + + #[test] + fn transcript_pager_renders_canonical_entries_scrolls_and_closes_with_ctrl_t() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.transcripts.insert( + "one".to_string(), + SessionTranscriptState::Loaded(vec![TranscriptEntry { + id: "entry-1".to_string(), + kind: EntryKind::Assistant, + text: (0..16) + .map(|index| format!("assistant line {index}")) + .collect::>() + .join("\n"), + streaming: false, + status: None, + summary: Vec::new(), + }]), + ); + assert_eq!(picker.open_transcript_pager(Locale::EnUs), None); + + let mut terminal = Terminal::new(TestBackend::new(40, 8)).expect("terminal"); + terminal + .draw(|frame| render_with_locale(frame, &picker, Locale::EnUs)) + .expect("draw"); + let text = buffer_text(&terminal); + assert!(text.contains("assistant line")); + let pager = picker.transcript_pager.as_mut().expect("pager"); + assert_eq!( + pager + .overlay + .handle_event(&Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Home, + KeyModifiers::NONE, + ))), + PagerAction::Consumed + ); + assert_eq!( + pager + .overlay + .handle_event(&Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('t'), + KeyModifiers::CONTROL, + ))), + PagerAction::Close + ); + } + + #[test] + fn raw_ctrl_t_keycode_matches_codex_resume_shortcut() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('\u{0014}'), + KeyModifiers::NONE, + ))), + PickerAction::OpenTranscript + ); + } + #[test] fn ctrl_e_toggles_selected_session_expansion_and_transcript_loading() { let mut picker = PickerState::new( @@ -1354,16 +1685,12 @@ mod tests { }]), ); let lines = render_expanded_session_details(&picker.threads[0], &picker, 24, Locale::EnUs); - assert!( - lines - .iter() - .any(|line| line.to_string().contains("assistant")) - ); - assert!( - lines - .iter() - .all(|line| display_width(&line.to_string()) <= 24) - ); + assert!(lines + .iter() + .any(|line| line.to_string().contains("assistant"))); + assert!(lines + .iter() + .all(|line| display_width(&line.to_string()) <= 24)); } #[test] @@ -1444,4 +1771,124 @@ mod tests { assert!(!picker.loading); assert_eq!(picker.selected_thread_id(), Some("fresh")); } + + #[test] + fn thread_pages_append_unique_threads_and_keep_the_next_cursor() { + let mut picker = PickerState::new( + Vec::new(), + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + let first_token = picker.begin_load(); + picker.apply_thread_page( + first_token, + ThreadPage { + threads: vec![ + thread("one", "first", false), + thread("two", "second", false), + ], + next_cursor: Some("cursor-1".to_string()), + }, + ); + assert_eq!(picker.threads.len(), 2); + assert!(matches!( + picker.pagination.next_cursor.as_ref(), + Some(PageCursor::AppServer(cursor)) if cursor == "cursor-1" + )); + picker.selected = 1; + assert!(picker.should_load_more()); + + let second_token = picker.begin_load(); + picker.apply_thread_page( + second_token, + ThreadPage { + threads: vec![ + thread("two", "duplicate", false), + thread("three", "third", false), + ], + next_cursor: None, + }, + ); + assert_eq!( + picker + .threads + .iter() + .map(|thread| thread.id.as_str()) + .collect::>(), + vec!["one", "two", "three"] + ); + assert!(picker.pagination.next_cursor.is_none()); + assert!(!picker.should_load_more()); + } + + #[test] + fn query_mutation_invalidates_a_previous_page_cursor() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.pagination.complete_page( + Some(PageCursor::AppServer("cursor-1".to_string())), + 0, + false, + ); + picker.seen_cursors.insert("cursor-1".to_string()); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('x'), + KeyModifiers::NONE, + ))), + PickerAction::Reload + ); + assert!(picker.pagination.next_cursor.is_none()); + assert!(picker.seen_cursors.is_empty()); + } + + #[test] + fn page_navigation_matches_codex_shape_and_reaches_the_next_cursor() { + let mut picker = PickerState::new( + (0..12) + .map(|index| thread(&format!("thread-{index}"), "preview", false)) + .collect(), + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.pagination.complete_page( + Some(PageCursor::AppServer("cursor-1".to_string())), + 12, + false, + ); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::PageDown, + KeyModifiers::NONE, + ))), + PickerAction::MoveDown + ); + assert_eq!(picker.selected, 10); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::PageDown, + KeyModifiers::NONE, + ))), + PickerAction::MoveDown + ); + assert_eq!(picker.selected, 11); + assert!(picker.should_load_more()); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Home, + KeyModifiers::NONE, + ))), + PickerAction::MoveUp + ); + assert_eq!(picker.selected, 0); + } } diff --git a/lime-rs/crates/tui/src/resume_picker/archive.rs b/lime-rs/crates/tui/src/resume_picker/archive.rs index df4e0ed3a..6c0a8b7f7 100644 --- a/lime-rs/crates/tui/src/resume_picker/archive.rs +++ b/lime-rs/crates/tui/src/resume_picker/archive.rs @@ -14,11 +14,11 @@ pub(super) enum ArchiveState { } impl PickerState { - pub(super) fn archive_shortcut_available(&self) -> bool { + pub(crate) fn archive_shortcut_available(&self) -> bool { matches!(self.archive_state, ArchiveState::Idle) && self.selected_thread_id().is_some() } - pub(super) fn request_archive_for_selected_session(&mut self) -> Option { + pub(crate) fn request_archive_for_selected_session(&mut self) -> Option { if !self.archive_shortcut_available() { return None; } @@ -30,7 +30,7 @@ impl PickerState { Some(thread_id) } - pub(super) fn request_unarchive_for_selected_session(&mut self) -> Option { + pub(crate) fn request_unarchive_for_selected_session(&mut self) -> Option { if !matches!(self.archive_state, ArchiveState::Idle) { return None; } @@ -42,7 +42,7 @@ impl PickerState { Some(thread_id) } - pub(super) fn handle_archive_result(&mut self, thread_id: String, result: anyhow::Result<()>) { + pub(crate) fn handle_archive_result(&mut self, thread_id: String, result: anyhow::Result<()>) { if self.archive_state != (ArchiveState::Pending { thread_id: thread_id.clone(), @@ -60,6 +60,13 @@ impl PickerState { self.threads.retain(|thread| thread.id != thread_id); self.transcript_previews.remove(&thread_id); self.transcripts.remove(&thread_id); + if self + .transcript_pager + .as_ref() + .is_some_and(|pager| pager.thread_id == thread_id) + { + self.transcript_pager = None; + } if self.expanded_thread_id.as_deref() == Some(thread_id.as_str()) { self.expanded_thread_id = None; } @@ -67,7 +74,7 @@ impl PickerState { self.status_message = None; } - pub(super) fn handle_unarchive_result( + pub(crate) fn handle_unarchive_result( &mut self, thread_id: String, result: anyhow::Result, diff --git a/lime-rs/crates/tui/src/resume_picker/archive_tests.rs b/lime-rs/crates/tui/src/resume_picker/archive_tests.rs index dadf1d7e4..94f3462e4 100644 --- a/lime-rs/crates/tui/src/resume_picker/archive_tests.rs +++ b/lime-rs/crates/tui/src/resume_picker/archive_tests.rs @@ -128,11 +128,9 @@ fn restore_failure_preserves_archived_thread_and_allows_retry() { .request_unarchive_for_selected_session() .expect("selected archived thread"); - assert!( - picker - .handle_unarchive_result(thread_id.clone(), Err(anyhow::anyhow!("restore denied"))) - .is_none() - ); + assert!(picker + .handle_unarchive_result(thread_id.clone(), Err(anyhow::anyhow!("restore denied"))) + .is_none()); assert_eq!(picker.threads.len(), 1); assert_eq!( picker.status_message.as_deref(), diff --git a/lime-rs/crates/tui/src/runtime.rs b/lime-rs/crates/tui/src/runtime.rs index 7895c8444..c671028f0 100644 --- a/lime-rs/crates/tui/src/runtime.rs +++ b/lime-rs/crates/tui/src/runtime.rs @@ -1,13 +1,17 @@ use std::ffi::OsString; +use std::future::Future; use std::path::PathBuf; +use std::pin::Pin; use std::time::Duration; -use anyhow::{Context, Result, anyhow, bail}; -use app_server_client::{RemoteTransportConfig, SessionEvent, StdioTransportConfig}; +use anyhow::{anyhow, bail, Context, Result}; +use app_server_client::{AppServerEvent, RemoteTransportConfig, StdioTransportConfig}; use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, UserInput}; use futures::StreamExt; use serde::Serialize; +use crate::app::event_dispatch::{EventContext, EventDispatch}; +use crate::app::reconnect::{reconnect_session, ReconnectedSession}; use crate::app::{App, AppAction}; use crate::app_server_session::AppServerSession; use crate::bottom_pane::AppServerResponse; @@ -16,10 +20,12 @@ use crate::clipboard_paste::paste_image_to_temp_png; use crate::external_editor::edit_draft; use crate::locale::Locale; use crate::projection::ConversationProjection; -use crate::reconnect::reconnect_session; -use crate::resume_picker::run_resume_picker_with_app_server; -use crate::settings::{EFFORTS, SettingsCommand, cycle_setting, parse_settings_command}; -use crate::tui::{TerminalGuard, TuiEvent}; +use crate::resume_picker::{ + run_resume_picker_with_app_server, PickerAction, PickerLoadEvent, PickerState, + SessionPickerAction, SessionStatus, +}; +use crate::settings::{parse_settings_command, SettingsCommand}; +use crate::tui::{Tui, TuiEvent}; use crate::view; #[derive(Debug, Clone)] @@ -92,11 +98,20 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { effort = response.reasoning_effort; } } else { - session + let response = session .as_mut() .expect("session available during setup") .start_thread(options.cwd.clone(), model.clone(), model_provider.clone()) .await?; + if model.is_none() { + model = Some(response.model); + } + if model_provider.is_none() { + model_provider = Some(response.model_provider); + } + if effort.is_none() { + effort = response.reasoning_effort; + } } let permission_profiles = session .as_ref() @@ -124,6 +139,13 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .thread_id()? .to_string(), ); + let collaboration_modes = session + .as_ref() + .expect("session available during setup") + .list_collaboration_modes() + .await + .unwrap_or_default(); + app.set_collaboration_modes(collaboration_modes); session .as_ref() .expect("session available during setup") @@ -153,11 +175,8 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .projection .set_status(format!("prompt history unavailable: {error}")), } - refresh_queued_submissions( - session.as_ref().expect("session available during setup"), - &mut app, - ) - .await; + app.refresh_queued_submissions(session.as_ref().expect("session available during setup")) + .await; Ok(()) } .await; @@ -169,7 +188,7 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .await; return Err(error); } - let mut terminal = match TerminalGuard::enter().context("failed to initialize terminal") { + let mut terminal = match Tui::enter().context("failed to initialize terminal") { Ok(terminal) => terminal, Err(error) => { let _ = session @@ -186,20 +205,134 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { let mut status_tick = tokio::time::interval(Duration::from_secs(1)); status_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); let run_result: Result<()> = async { + let mut resume_picker_load_tx: Option> = + None; + let mut resume_picker_load_rx: Option> = + None; + let mut reconnect: Option>>>> = None; + let mut reconnect_thread_id: Option = None; + let mut reconnect_failed = false; loop { + if session.is_none() && reconnect.is_none() && !reconnect_failed { + if let Some(thread_id) = reconnect_thread_id.as_deref() { + reconnect = Some(Box::pin(reconnect_session( + options.clone(), + thread_id.to_string(), + model.clone(), + model_provider.clone(), + effort.clone(), + permissions.clone(), + ))); + } else { + reconnect_failed = true; + app.projection + .set_status("reconnect unavailable: thread id missing"); + } + } + terminal + .sync_viewport() + .context("failed to synchronize terminal viewport")?; + if let Some(active_session) = session.as_ref() { + if let (Some(picker), Some(sender)) = + (app.resume_picker.as_mut(), resume_picker_load_tx.as_ref()) + { + if let Some(thread_id) = picker.selected_thread_id().map(ToOwned::to_owned) { + crate::resume_picker::spawn_preview_load( + active_session.request_handle(), + sender, + picker, + thread_id, + ); + } + } + } terminal .terminal_mut() .draw(|frame| view::render(frame, &app)) .context("failed to render terminal")?; tokio::select! { + resume_event = async { + match resume_picker_load_rx.as_mut() { + Some(receiver) => receiver.recv().await, + None => std::future::pending::>().await, + } + }, if resume_picker_load_rx.is_some() => { + let Some(resume_event) = resume_event else { + resume_picker_load_rx = None; + resume_picker_load_tx = None; + continue; + }; + let Some(picker) = app.resume_picker.as_mut() else { + continue; + }; + match resume_event { + PickerLoadEvent::Threads { token, result } => match result { + Ok(page) => { + picker.apply_thread_page(token, page); + if picker.threads.is_empty() && picker.has_more_pages() { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_thread_load( + session + .as_ref() + .expect("session available during resume picker") + .request_handle(), + sender, + picker, + ); + } + } + } + Err(error) if picker.load_token == token => { + picker.fail_thread_load(token, error); + } + Err(_) => {} + }, + PickerLoadEvent::Preview { thread_id, result } => { + picker.set_transcript_preview(thread_id, result.unwrap_or_default()); + } + PickerLoadEvent::Transcript { thread_id, result } => { + picker.set_transcript(thread_id, result); + } + PickerLoadEvent::Archive { thread_id, result } => { + picker.handle_archive_result(thread_id, result); + } + PickerLoadEvent::Unarchive { thread_id, result } => { + if let Some(crate::resume_picker::SessionSelection::Resume(target)) = + picker.handle_unarchive_result(thread_id, *result) + { + let thread_id = target.thread_id; + app.resume_picker = None; + resume_picker_load_rx = None; + resume_picker_load_tx = None; + app.agents_overview = None; + match app.resume_target_session( + session + .as_mut() + .expect("session available during resume picker"), + thread_id, + &mut model, + &mut model_provider, + &mut effort, + &mut permissions, + &options, + ) + .await + { + Ok(()) => app.projection.set_status("archived session restored"), + Err(error) => app + .projection + .set_status(format!("resume failed: {error}")), + } + } + } + } + } _ = status_tick.tick(), if app.projection.active_turn_id().is_some() => { frame_requester.schedule_frame(); } event = input.next() => { - let Some(event) = event else { - break; - }; + let Some(event) = event else { break }; let event = match event { TuiEvent::Draw => { if app.projection.active_turn_id().is_some() { @@ -207,19 +340,73 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { } continue; } - TuiEvent::Key(key) => crossterm::event::Event::Key(key), - TuiEvent::Paste(text) => crossterm::event::Event::Paste(text), TuiEvent::Resize(size) => { - crossterm::event::Event::Resize(size.width, size.height) + terminal.update_viewport(size, size.height); + TuiEvent::Resize(size) } TuiEvent::Resume => continue, - TuiEvent::FocusGained => crossterm::event::Event::FocusGained, - TuiEvent::FocusLost => crossterm::event::Event::FocusLost, + event => event, }; - match app.handle_terminal_event(event) { + let connected = session.is_some(); + let action = app.handle_tui_event(event, connected); + if !connected { + if matches!(action, AppAction::Quit) { + break; + } + continue; + } + let action = match app + .handle_event( + action, + EventContext { + session: session + .as_mut() + .expect("session available during TUI action dispatch"), + model: &mut model, + model_provider: &mut model_provider, + effort: &mut effort, + permissions: &mut permissions, + }, + ) + .await? + { + EventDispatch::Handled => continue, + EventDispatch::Unhandled(action) => action, + }; + match action { AppAction::Submit(prompt) => { + if !app.can_accept_direct_input() { + continue; + } if let Some(command) = parse_settings_command(&prompt) { match command { + Ok(SettingsCommand::Plan) => { + let Some(collaboration_mode) = app.plan_mode() else { + app.projection + .set_status("plan mode unavailable on this server"); + continue; + }; + match session + .as_ref() + .expect("session available during TUI") + .update_collaboration_mode(collaboration_mode.clone()) + .await + { + Ok(()) => { + model = Some(collaboration_mode.settings.model.clone()); + effort = collaboration_mode.settings.reasoning_effort.clone(); + app.set_settings( + model.clone(), + model_provider.clone(), + effort.clone(), + permissions.clone(), + ); + app.collaboration_mode = Some(collaboration_mode); + app.projection.set_status("plan mode"); + } + Err(error) => app.projection.set_status(error.to_string()), + } + } Ok(SettingsCommand::ModelPicker) => { match session .as_ref() @@ -375,6 +562,9 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { } } AppAction::Queue(prompt) => { + if !app.can_accept_direct_input() { + continue; + } let images = app.take_pending_images(); let input = submission_input(prompt.clone(), &images); match session @@ -410,17 +600,15 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { app.projection.set_status("queued input editing"); } Ok(true) => { - refresh_queued_submissions( + app.refresh_queued_submissions( session.as_ref().expect("session available during TUI"), - &mut app, ) .await; app.projection.set_status("queued input unavailable"); } Ok(false) => { - refresh_queued_submissions( + app.refresh_queued_submissions( session.as_ref().expect("session available during TUI"), - &mut app, ) .await; app.projection.set_status("queued input unavailable"); @@ -430,59 +618,6 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .set_status(format!("queue edit failed: {error}")), } } - action @ (AppAction::DecreaseEffort | AppAction::IncreaseEffort) => { - let direction = if matches!(action, AppAction::DecreaseEffort) { - -1 - } else { - 1 - }; - let next = cycle_setting(&EFFORTS, effort.as_deref(), direction); - match session - .as_ref() - .expect("session available during TUI") - .update_settings(None, None, Some(next.clone()), None) - .await - { - Ok(()) => { - effort = Some(next.clone()); - app.set_settings( - model.clone(), - model_provider.clone(), - effort.clone(), - permissions.clone(), - ); - app.projection.set_status(format!("effort: {next}")); - } - Err(error) => app.projection.set_status(error.to_string()), - } - } - action @ (AppAction::PreviousPermissions | AppAction::NextPermissions) => { - let direction = if matches!(action, AppAction::PreviousPermissions) { - -1 - } else { - 1 - }; - let next = app - .cycle_permission_profile(permissions.as_deref(), direction); - match session - .as_ref() - .expect("session available during TUI") - .update_settings(None, None, None, Some(next.clone())) - .await - { - Ok(()) => { - permissions = Some(next.clone()); - app.set_settings( - model.clone(), - model_provider.clone(), - effort.clone(), - permissions.clone(), - ); - app.projection.set_status(format!("permissions: {next}")); - } - Err(error) => app.projection.set_status(error.to_string()), - } - } AppAction::CopyLastResponse => { copy_last_response_with(&mut app, copy_to_clipboard); } @@ -517,17 +652,13 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { } AppAction::OpenExternalEditor => { let draft = app.composer.text().to_string(); - if let Err(error) = terminal.suspend() { - app.projection.set_status(format!("editor unavailable: {error}")); - continue; - } - let edited = edit_draft(&draft, &options.cwd).await; - let resume_result = terminal.resume(); - match (edited, resume_result) { - (Ok(Some(text)), Ok(())) => app.replace_composer(text), - (Ok(None), Ok(())) => app.projection.set_status("editor draft empty"), - (Err(error), Ok(())) => app.projection.set_status(error.to_string()), - (_, Err(error)) => return Err(anyhow!("failed to resume terminal after editor: {error}")), + let edited = terminal + .with_restored(|| edit_draft(&draft, &options.cwd)) + .await; + match edited { + Ok(Some(text)) => app.replace_composer(text), + Ok(None) => app.projection.set_status("editor draft empty"), + Err(error) => app.projection.set_status(error.to_string()), } } AppAction::ScrollUp => { @@ -540,118 +671,209 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { } AppAction::ScrollTop => app.scroll_top(), AppAction::ScrollBottom => app.scroll_bottom(), - AppAction::Respond(response) => { - if let Err(error) = session - .as_ref() - .expect("session available during TUI") - .respond(response) - .await - { - app.projection.set_status(error.to_string()); + AppAction::OpenResumePicker => { + if app.resume_picker.is_none() { + let (load_tx, load_rx) = tokio::sync::mpsc::unbounded_channel(); + let mut picker = PickerState::new( + Vec::new(), + SessionPickerAction::Resume, + SessionStatus::Active, + Some(app.cwd.clone()), + false, + ); + crate::resume_picker::spawn_thread_load( + session + .as_ref() + .expect("session available during resume picker") + .request_handle(), + &load_tx, + &mut picker, + ); + app.resume_picker = Some(picker); + resume_picker_load_tx = Some(load_tx); + resume_picker_load_rx = Some(load_rx); } } - AppAction::SelectModel(selection) => { - match session + AppAction::ResumePicker(action) => { + let Some(picker) = app.resume_picker.as_mut() else { + continue; + }; + let request_handle = session .as_ref() - .expect("session available during TUI") - .update_settings( - Some(selection.model.clone()), - Some(selection.provider.clone()), - None, - None, - ) - .await - { - Ok(()) => { - model = Some(selection.model.clone()); - model_provider = Some(selection.provider.clone()); - app.set_settings( - model.clone(), - model_provider.clone(), - effort.clone(), - permissions.clone(), - ); - app.projection.set_status("settings updated"); + .expect("session available during resume picker") + .request_handle(); + match action { + PickerAction::Select => { + let selected = picker.selected_thread_id().map(ToOwned::to_owned); + app.resume_picker = None; + resume_picker_load_rx = None; + resume_picker_load_tx = None; + app.agents_overview = None; + if let Some(thread_id) = selected { + app.projection + .set_status(format!("resuming session {thread_id}")); + match app.resume_target_session( + session + .as_mut() + .expect("session available during resume picker"), + thread_id, + &mut model, + &mut model_provider, + &mut effort, + &mut permissions, + &options, + ) + .await + { + Ok(()) => {} + Err(error) => app + .projection + .set_status(format!("resume failed: {error}")), + } + } } - Err(error) => app.projection.set_status(error.to_string()), + PickerAction::Restore => { + if let Some(thread_id) = + picker.request_unarchive_for_selected_session() + { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_unarchive_request( + request_handle, + sender, + thread_id, + ); + } + } + } + PickerAction::Archive => { + if let Some(thread_id) = + picker.request_archive_for_selected_session() + { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_archive_request( + request_handle, + sender, + thread_id, + ); + } + } + } + PickerAction::ToggleStatus + | PickerAction::ToggleFilter + | PickerAction::ToggleSort + | PickerAction::Reload => { + match action { + PickerAction::ToggleStatus => picker.toggle_status(), + PickerAction::ToggleFilter => picker.toggle_filter(), + PickerAction::ToggleSort => picker.toggle_sort(), + PickerAction::Reload => {} + _ => unreachable!(), + } + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_thread_load( + request_handle, + sender, + picker, + ); + } + } + PickerAction::ToggleDensity => picker.toggle_density(), + PickerAction::ToggleExpanded => { + if let Some(thread_id) = picker.toggle_selected_expansion() { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_transcript_load( + request_handle, + sender, + thread_id, + ); + } + } + } + PickerAction::OpenTranscript => { + if let Some(thread_id) = picker.open_transcript_pager(app.locale) { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_transcript_load( + request_handle, + sender, + thread_id, + ); + } + } + } + PickerAction::MoveDown if picker.should_load_more() => { + if let Some(sender) = resume_picker_load_tx.as_ref() { + crate::resume_picker::spawn_thread_load( + request_handle, + sender, + picker, + ); + } + } + PickerAction::MoveUp + | PickerAction::MoveDown + | PickerAction::None + | PickerAction::Cancel => {} } } + AppAction::SwitchThread(thread_id) => { + match app.resume_target_session( + session.as_mut().expect("session available during TUI"), + thread_id, + &mut model, + &mut model_provider, + &mut effort, + &mut permissions, + &options, + ) + .await + { + Ok(()) => app.projection.set_status("switched agent"), + Err(error) => app.projection.set_status(format!( + "agent switch failed: {error}" + )), + } + } + AppAction::DecreaseEffort + | AppAction::IncreaseEffort + | AppAction::PreviousPermissions + | AppAction::NextPermissions + | AppAction::Respond(_) + | AppAction::SelectModel(_) + | AppAction::ChangeCollaborationMode(_) + | AppAction::RefreshAgentsOverview + | AppAction::DispatchAgentsOverviewTask { .. } + | AppAction::RenameAgentsOverviewThread { .. } + | AppAction::StopAgentsOverviewThread { .. } => { + unreachable!("App Server actions are handled by app::event_dispatch") + } AppAction::Quit => break, AppAction::None => {} } } - event = session - .as_mut() - .expect("session available during TUI") - .next_event() => { - let event = event.unwrap_or_else(|| SessionEvent::Disconnected { + event = async { + match session.as_mut() { + Some(session) => session.next_event().await, + None => std::future::pending::>().await, + } + }, if session.is_some() => { + let event = event.unwrap_or_else(|| AppServerEvent::Disconnected { message: "app-server event stream closed".to_string(), }); - match event { - SessionEvent::Notification(notification) => { - let queue_changed = matches!( - notification.as_ref(), - ServerNotification::ThreadQueueChanged(params) - if app.thread_id.as_deref() == Some(params.thread_id.as_str()) - ); - app.apply_notification(*notification); - if queue_changed { - refresh_queued_submissions( - session.as_ref().expect("session available during TUI"), - &mut app, - ) - .await; - } - } - SessionEvent::ServerRequest(request) => { - match *request { - ServerRequest::CurrentTimeRead { id, .. } => { - if let Err(error) = session - .as_ref() - .expect("session available during TUI") - .respond_current_time(id) - .await - { - app.projection.set_status(error.to_string()); - } - } - request => { - match app.bottom_pane.enqueue(request) { - Ok(()) => app.pager_overlay = None, - Err(request) => { - if let Err(error) = session - .as_ref() - .expect("session available during TUI") - .reject_server_request(request) - .await - { - app.projection.set_status(error.to_string()); - } - } - } - } - } - } - SessionEvent::RawServerRequest(request) => { - if let Err(error) = session - .as_ref() - .expect("session available during TUI") - .reject_raw_server_request(request) - .await - { - app.projection.set_status(error.to_string()); - } - } - SessionEvent::Disconnected { message } => { + let disconnected_message = match &event { + AppServerEvent::Disconnected { message } => Some(message.clone()), + _ => None, + }; + app.handle_app_server_event( + session.as_ref().expect("session available during TUI"), + event, + ) + .await; + if disconnected_message.is_some() { let thread_id = session .as_ref() .expect("session available during TUI") .thread_id()? .to_string(); - app.bottom_pane.clear(); - app.model_picker = None; - app.pager_overlay = None; - app.projection.set_status(format!("reconnecting: {message}")); terminal .terminal_mut() .draw(|frame| view::render(frame, &app)) @@ -660,33 +882,53 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .take() .expect("session available during reconnect"); let _ = old_session.shutdown().await; - let reconnected = reconnect_session( - &options, - &mut app, - &thread_id, + reconnect_thread_id = Some(thread_id); + reconnect_failed = false; + } + } + result = async { + match reconnect.as_mut() { + Some(future) => future.await, + None => std::future::pending::>().await, + } + }, if reconnect.is_some() => { + reconnect = None; + match result { + Ok(reconnected) => { + let active_profile = reconnected + .session + .active_permission_profile() + .map(str::to_string); + app.hydrate_thread(reconnected.thread); + app.set_permission_profiles(reconnected.permission_profiles); + app.set_collaboration_modes( + reconnected + .session + .list_collaboration_modes() + .await + .unwrap_or_default(), + ); + if options.permissions.is_none() { + if let Some(active_profile) = active_profile { + permissions = Some(active_profile); + } + } + app.set_settings( model.clone(), model_provider.clone(), effort.clone(), permissions.clone(), - ) - .await?; - if options.permissions.is_none() { - if let Some(active_profile) = - reconnected.active_permission_profile() - { - permissions = Some(active_profile.to_string()); - app.set_settings( - model.clone(), - model_provider.clone(), - effort.clone(), - permissions.clone(), - ); - } - } - refresh_queued_submissions(&reconnected, &mut app).await; - session = Some(reconnected); + ); + app.refresh_queued_submissions(&reconnected.session).await; + session = Some(reconnected.session); + reconnect_thread_id = None; + reconnect_failed = false; + app.projection.set_status("reconnected"); + } + Err(error) => { + reconnect_failed = true; + app.projection.set_status(format!("reconnect failed: {error}")); } - SessionEvent::RawNotification(_) => {} } } } @@ -712,7 +954,7 @@ fn is_no_active_turn_error(error: &anyhow::Error) -> bool { }) } -fn current_transcript_page_size(terminal: &mut TerminalGuard, app: &App) -> Result { +fn current_transcript_page_size(terminal: &mut Tui, app: &App) -> Result { let size = terminal .terminal_mut() .size() @@ -744,17 +986,6 @@ async fn persist_prompt(session: &AppServerSession, app: &mut App, prompt: Strin } } -async fn refresh_queued_submissions(session: &AppServerSession, app: &mut App) { - match session.list_queued_submissions(100).await { - Ok(submissions) => app.set_queued_submissions(submissions), - Err(error) => { - app.set_queued_submissions(Vec::new()); - app.projection - .set_status(format!("queue unavailable: {error}")); - } - } -} - fn submission_input(prompt: String, images: &[PathBuf]) -> Vec { let mut input = images .iter() @@ -839,14 +1070,17 @@ async fn run_exec_with_session( projection.hydrate_thread(response.thread); (thread_id, projection) } else { - let thread_id = session + let response = session .start_thread( options.tui.cwd.clone(), options.tui.model.clone(), options.tui.model_provider.clone(), ) .await?; - (thread_id, ConversationProjection::default()) + ( + response.thread.id.clone(), + ConversationProjection::default(), + ) }; session .update_settings( @@ -865,7 +1099,7 @@ async fn run_exec_with_session( .await .ok_or_else(|| anyhow!("App Server disconnected before turn completion"))?; match event { - SessionEvent::Notification(notification) => { + AppServerEvent::ServerNotification(notification) => { let completed = matches!( notification.as_ref(), ServerNotification::TurnCompleted(params) if params.turn.id == turn_id @@ -875,7 +1109,7 @@ async fn run_exec_with_session( break; } } - SessionEvent::ServerRequest(request) => match *request { + AppServerEvent::ServerRequest(request) => match *request { ServerRequest::CurrentTimeRead { id, .. } => { session.respond_current_time(id).await?; } @@ -884,11 +1118,8 @@ async fn run_exec_with_session( Err(request) => session.reject_server_request(request).await?, }, }, - SessionEvent::RawServerRequest(request) => { - session.reject_raw_server_request(request).await?; - } - SessionEvent::Disconnected { message } => bail!(message), - SessionEvent::RawNotification(_) => {} + AppServerEvent::Disconnected { message } => bail!(message), + AppServerEvent::Lagged { .. } => {} } } diff --git a/lime-rs/crates/tui/src/runtime_pty_tests.rs b/lime-rs/crates/tui/src/runtime_pty_tests.rs index bd79cda8b..6a85fd8e6 100644 --- a/lime-rs/crates/tui/src/runtime_pty_tests.rs +++ b/lime-rs/crates/tui/src/runtime_pty_tests.rs @@ -6,7 +6,7 @@ use std::sync::mpsc; use std::thread; use std::time::{Duration, Instant}; -use portable_pty::{CommandBuilder, PtySize, native_pty_system}; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; #[test] fn real_pty_restores_terminal_after_visible_turn_completion() { @@ -30,7 +30,10 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { std::env::var("LIME_TEST_TERMINAL_SCENARIO").unwrap_or_else(|_| "complete".to_string()); // Keep the interrupt backend alive long enough for the real PTY event // loop to deliver turn/interrupt before the fixture timeout fires. - let backend_timeout_ms = if matches!(scenario.as_str(), "interrupt" | "queue-edit") { + let backend_timeout_ms = if matches!( + scenario.as_str(), + "interrupt" | "queue-edit" | "agents-overview" + ) { "30000" } else { "5000" @@ -110,14 +113,123 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { } } Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, - Err(_) => break, + Err(error) => { + let _ = output_tx.send(format!("PTY_READER_ERROR: {error}\n").into_bytes()); + break; + } } } }); let mut output = String::new(); + let mut agents_overview_screen = None; + let mut agents_overview_renamed_screen = None; + let mut agents_overview_resumed_screen = None; wait_for_marker(&output_rx, &mut output, "ready", Duration::from_secs(10)); - if scenario == "complete" { + if scenario == "agents-overview" { + writer + .write_all(b"/agents\r") + .expect("open agents overview"); + writer.flush().expect("flush agents overview command"); + wait_for_marker( + &output_rx, + &mut output, + "Agent command center", + Duration::from_secs(10), + ); + + writer.write_all(&[14]).expect("start new task with Ctrl-N"); + writer.flush().expect("flush Ctrl-N"); + wait_for_marker( + &output_rx, + &mut output, + "New task:", + Duration::from_secs(10), + ); + writer + .write_all(prompt.as_bytes()) + .expect("write overview task prompt"); + writer.write_all(b"\r").expect("submit overview task"); + writer.flush().expect("flush overview task"); + wait_for_ledger_kind_and_scenario( + &ledger_path, + "turnStart", + &scenario, + Duration::from_secs(10), + ); + wait_for_screen_marker( + &output_rx, + &mut output, + "background task started", + Duration::from_secs(10), + ); + wait_for_screen_marker( + &output_rx, + &mut output, + "1 working", + Duration::from_secs(10), + ); + agents_overview_screen = Some(terminal_screen_text(&output)); + + writer + .write_all(b"\x1b[B") + .expect("select background thread"); + writer.write_all(&[18]).expect("rename with Ctrl-R"); + writer.flush().expect("flush overview rename shortcut"); + wait_for_screen_marker(&output_rx, &mut output, "Rename:", Duration::from_secs(10)); + writer + .write_all(&[127; 32]) + .expect("clear existing task name"); + writer + .write_all(b"Gate B background") + .expect("write background task name"); + writer + .write_all(b"\r") + .expect("submit background task name"); + writer.flush().expect("flush background task rename"); + wait_for_screen_marker( + &output_rx, + &mut output, + "Gate B background", + Duration::from_secs(10), + ); + agents_overview_renamed_screen = Some(terminal_screen_text(&output)); + + let stop_output_at = output.len(); + writer.write_all(&[24]).expect("stop with Ctrl-X"); + writer.flush().expect("flush overview stop shortcut"); + wait_for_ledger_kind_and_scenario( + &ledger_path, + "turnCancel", + &scenario, + Duration::from_secs(10), + ); + wait_for_marker_after( + &output_rx, + &mut output, + stop_output_at, + "stopping", + Duration::from_secs(10), + ); + + writer.write_all(b"\r").expect("resume background thread"); + writer.flush().expect("flush background thread resume"); + wait_for_screen_marker( + &output_rx, + &mut output, + "switched agent", + Duration::from_secs(10), + ); + wait_for_screen_marker( + &output_rx, + &mut output, + "AGENTS_OVERVIEW_READY", + Duration::from_secs(10), + ); + agents_overview_resumed_screen = Some(terminal_screen_text(&output)); + writer.write_all(&[4]).expect("exit TUI"); + writer.flush().expect("flush TUI exit"); + } else if scenario == "complete" { writer.write_all(b"/status\r").expect("open status pager"); writer.flush().expect("flush status command"); wait_for_marker(&output_rx, &mut output, "/ STATUS", Duration::from_secs(10)); @@ -148,140 +260,145 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { } else { writer.write_all(prompt.as_bytes()).expect("write prompt"); } - writer.write_all(b"\r").expect("submit prompt"); - writer.flush().expect("flush prompt"); - let marker = match scenario.as_str() { - "approval" => "Allow terminal command?", - // Cursor-addressed renders may split the full question across writes; the - // stable question title is sufficient to prove the prompt is visible. - "user-input" => "Choose", - "interrupt" => "INTERRUPT_READY", - "queue-edit" => "QUEUE_EDIT_READY", - "failure" => "fixture backend failure", - _ => &completed_text, - }; + if scenario != "agents-overview" { + writer.write_all(b"\r").expect("submit prompt"); + writer.flush().expect("flush prompt"); + } let visible_result = match scenario.as_str() { + "agents-overview" => "Agent command center", "interrupt" => "INTERRUPT_READY", "queue-edit" => &queue_prompt, "failure" => "fixture backend failure", _ => &completed_text, }; - wait_for_marker(&output_rx, &mut output, marker, Duration::from_secs(10)); - if scenario == "approval" { - writer.write_all(b"y").expect("approve command"); - writer.flush().expect("flush approval"); - wait_for_marker( - &output_rx, - &mut output, - &completed_text, - Duration::from_secs(10), - ); - } else if scenario == "user-input" { - writer.write_all(b"\r").expect("answer user input"); - writer.flush().expect("flush user input"); - wait_for_marker( - &output_rx, - &mut output, - &completed_text, - Duration::from_secs(10), - ); - } - if scenario == "complete" { - let transcript_at = output.len(); - writer.write_all(&[20]).expect("open transcript Ctrl-T"); - writer.flush().expect("flush transcript shortcut"); - wait_for_marker_after( - &output_rx, - &mut output, - transcript_at, - "Ctrl+T/Esc/Q close", - Duration::from_secs(10), - ); - wait_for_marker_after( - &output_rx, - &mut output, - transcript_at, - &completed_text, - Duration::from_secs(10), - ); - writer.write_all(&[20]).expect("close transcript Ctrl-T"); - writer.flush().expect("flush transcript close"); - } - if scenario == "queue-edit" { - let queued_at = output.len(); - writer - .write_all(queue_prompt.as_bytes()) - .expect("write queued follow-up"); - writer.write_all(b"\t").expect("queue follow-up with Tab"); - writer.flush().expect("flush queued follow-up"); - wait_for_marker_after( - &output_rx, - &mut output, - queued_at, - "queued (1)", - Duration::from_secs(10), - ); - wait_for_marker_after( - &output_rx, - &mut output, - queued_at, - &queue_prompt, - Duration::from_secs(10), - ); - wait_for_marker_after( - &output_rx, - &mut output, - queued_at, - "Alt+Up edit last queued input", - Duration::from_secs(10), - ); + if scenario != "agents-overview" { + let marker = match scenario.as_str() { + "approval" => "Allow terminal command?", + // Cursor-addressed renders may split the full question across writes; the + // stable question title is sufficient to prove the prompt is visible. + "user-input" => "Choose", + "interrupt" => "INTERRUPT_READY", + "queue-edit" => "QUEUE_EDIT_READY", + "failure" => "fixture backend failure", + _ => &completed_text, + }; + wait_for_marker(&output_rx, &mut output, marker, Duration::from_secs(10)); + if scenario == "approval" { + writer.write_all(b"y").expect("approve command"); + writer.flush().expect("flush approval"); + wait_for_marker( + &output_rx, + &mut output, + &completed_text, + Duration::from_secs(10), + ); + } else if scenario == "user-input" { + writer.write_all(b"\r").expect("answer user input"); + writer.flush().expect("flush user input"); + wait_for_marker( + &output_rx, + &mut output, + &completed_text, + Duration::from_secs(10), + ); + } + if scenario == "complete" { + let transcript_at = output.len(); + writer.write_all(&[20]).expect("open transcript Ctrl-T"); + writer.flush().expect("flush transcript shortcut"); + wait_for_marker_after( + &output_rx, + &mut output, + transcript_at, + "Ctrl+T/Esc/Q close", + Duration::from_secs(10), + ); + wait_for_marker_after( + &output_rx, + &mut output, + transcript_at, + &completed_text, + Duration::from_secs(10), + ); + writer.write_all(&[20]).expect("close transcript Ctrl-T"); + writer.flush().expect("flush transcript close"); + } + if scenario == "queue-edit" { + let queued_at = output.len(); + writer + .write_all(queue_prompt.as_bytes()) + .expect("write queued follow-up"); + writer.write_all(b"\t").expect("queue follow-up with Tab"); + writer.flush().expect("flush queued follow-up"); + wait_for_marker_after( + &output_rx, + &mut output, + queued_at, + "queued (1)", + Duration::from_secs(10), + ); + wait_for_marker_after( + &output_rx, + &mut output, + queued_at, + &queue_prompt, + Duration::from_secs(10), + ); + wait_for_marker_after( + &output_rx, + &mut output, + queued_at, + "Alt+Up edit last queued input", + Duration::from_secs(10), + ); - let edit_at = output.len(); - writer - .write_all(b"\x1b[1;3A") - .expect("edit queued follow-up with Alt-Up"); - writer.flush().expect("flush Alt-Up queue edit"); - wait_for_marker_after( - &output_rx, - &mut output, - edit_at, - "editing queued", - Duration::from_secs(10), - ); - wait_for_marker_after( - &output_rx, - &mut output, - edit_at, - "follow-up", - Duration::from_secs(10), - ); - } - if matches!(scenario.as_str(), "interrupt" | "queue-edit") { - wait_for_marker( - &output_rx, - &mut output, - "esc to interrupt", - Duration::from_secs(5), - ); - writer.write_all(b"\x1b").expect("interrupt with Escape"); - writer.flush().expect("flush Escape interrupt"); - wait_for_marker( - &output_rx, - &mut output, - "interrupting", - Duration::from_secs(5), - ); - wait_for_ledger_kind_and_scenario( - &ledger_path, - "turnCancel", - &scenario, - Duration::from_secs(5), - ); - writer.write_all(&[3]).expect("send quit Ctrl-C"); - writer.flush().expect("flush quit Ctrl-C"); - } else { - writer.write_all(&[4]).expect("exit TUI"); - writer.flush().expect("flush TUI exit"); + let edit_at = output.len(); + writer + .write_all(b"\x1b[1;3A") + .expect("edit queued follow-up with Alt-Up"); + writer.flush().expect("flush Alt-Up queue edit"); + wait_for_marker_after( + &output_rx, + &mut output, + edit_at, + "editing queued", + Duration::from_secs(10), + ); + wait_for_marker_after( + &output_rx, + &mut output, + edit_at, + "follow-up", + Duration::from_secs(10), + ); + } + if matches!(scenario.as_str(), "interrupt" | "queue-edit") { + wait_for_marker( + &output_rx, + &mut output, + "esc to interrupt", + Duration::from_secs(5), + ); + writer.write_all(b"\x1b").expect("interrupt with Escape"); + writer.flush().expect("flush Escape interrupt"); + wait_for_marker( + &output_rx, + &mut output, + "interrupting", + Duration::from_secs(5), + ); + wait_for_ledger_kind_and_scenario( + &ledger_path, + "turnCancel", + &scenario, + Duration::from_secs(5), + ); + writer.write_all(&[3]).expect("send quit Ctrl-C"); + writer.flush().expect("flush quit Ctrl-C"); + } else { + writer.write_all(&[4]).expect("exit TUI"); + writer.flush().expect("flush TUI exit"); + } } let deadline = Instant::now() + Duration::from_secs(5); @@ -336,6 +453,32 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { "queued input was not restored to the composer" ); } + if scenario == "agents-overview" { + let visible = agents_overview_screen.expect("agents overview screen"); + assert!( + visible.contains("Agent command center"), + "agents overview was not visible" + ); + assert!( + visible.contains("background task started"), + "server-backed overview task start was not visible" + ); + assert!( + agents_overview_renamed_screen + .expect("renamed agents overview screen") + .contains("Gate B background"), + "renamed background task was not visible" + ); + let resumed = agents_overview_resumed_screen.expect("resumed background thread screen"); + assert!( + resumed.contains("switched agent"), + "background thread resume status was not visible" + ); + assert!( + resumed.contains("AGENTS_OVERVIEW_READY"), + "background thread transcript was not restored" + ); + } assert!( output.contains("\u{1b}[?1049l"), "alternate screen not restored" @@ -443,6 +586,31 @@ fn wait_for_marker_after( } } +fn wait_for_screen_marker( + output_rx: &mpsc::Receiver>, + output: &mut String, + marker: &str, + timeout: Duration, +) { + let deadline = Instant::now() + timeout; + while !terminal_screen_text(output).contains(marker) { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + panic!("timed out waiting for screen marker {marker:?}; output: {output}"); + } + let chunk = output_rx.recv_timeout(remaining).unwrap_or_else(|_| { + panic!("PTY closed before screen marker {marker:?}; output: {output}") + }); + output.push_str(&String::from_utf8_lossy(&chunk)); + } +} + +fn terminal_screen_text(output: &str) -> String { + let mut parser = vt100::Parser::new(24, 100, 0); + parser.process(output.as_bytes()); + parser.screen().contents() +} + fn wait_for_ledger_kind_and_scenario( path: &PathBuf, kind: &str, diff --git a/lime-rs/crates/tui/src/settings.rs b/lime-rs/crates/tui/src/settings.rs index d8765dbfe..4a28a5623 100644 --- a/lime-rs/crates/tui/src/settings.rs +++ b/lime-rs/crates/tui/src/settings.rs @@ -1,6 +1,6 @@ -use anyhow::{Result, anyhow}; +use anyhow::{anyhow, Result}; -use crate::slash_command::{SlashCommand, command_from_prompt}; +use crate::slash_command::{command_from_prompt, SlashCommand}; pub(crate) const EFFORTS: [&str; 3] = ["low", "medium", "high"]; pub(crate) const PERMISSION_PROFILES: [&str; 3] = @@ -9,6 +9,7 @@ pub(crate) const PERMISSION_PROFILES: [&str; 3] = #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum SettingsCommand { ModelPicker, + Plan, Model { model: String, provider: Option, @@ -29,6 +30,11 @@ pub(crate) fn parse_settings_command(prompt: &str) -> Option Some(if value.is_some() { + Err(anyhow!("usage: /plan")) + } else { + Ok(SettingsCommand::Plan) + }), SlashCommand::Effort => Some(value.map_or_else( || Err(anyhow!("usage: /effort ")), |effort| Ok(SettingsCommand::Effort(effort.to_string())), @@ -37,7 +43,11 @@ pub(crate) fn parse_settings_command(prompt: &str) -> Option")), |permissions| Ok(SettingsCommand::Permissions(permissions.to_string())), )), - SlashCommand::Status | SlashCommand::Copy => None, + SlashCommand::Status + | SlashCommand::Copy + | SlashCommand::Agents + | SlashCommand::MultiAgents + | SlashCommand::Resume => None, } } @@ -85,6 +95,10 @@ mod tests { parse_settings_command("/model"), Some(Ok(SettingsCommand::ModelPicker)) )); + assert!(matches!( + parse_settings_command("/plan"), + Some(Ok(SettingsCommand::Plan)) + )); assert!(matches!( parse_settings_command("/model grok-test grok"), Some(Ok(SettingsCommand::Model { model, provider })) diff --git a/lime-rs/crates/tui/src/slash_command.rs b/lime-rs/crates/tui/src/slash_command.rs index 172e0199e..e9b8fb21e 100644 --- a/lime-rs/crates/tui/src/slash_command.rs +++ b/lime-rs/crates/tui/src/slash_command.rs @@ -3,28 +3,40 @@ use crate::locale::Locale; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SlashCommand { Model, + Plan, Effort, Permissions, Status, Copy, + Agents, + MultiAgents, + Resume, } impl SlashCommand { - pub(crate) const ALL: [Self; 5] = [ + pub(crate) const ALL: [Self; 9] = [ Self::Model, + Self::Plan, Self::Effort, Self::Permissions, Self::Status, Self::Copy, + Self::Agents, + Self::MultiAgents, + Self::Resume, ]; pub(crate) const fn command(self) -> &'static str { match self { Self::Model => "model", + Self::Plan => "plan", Self::Effort => "effort", Self::Permissions => "permissions", Self::Status => "status", Self::Copy => "copy", + Self::Agents => "agents", + Self::MultiAgents => "subagents", + Self::Resume => "resume", } } @@ -74,7 +86,17 @@ mod tests { fn command_catalog_is_the_prompt_parser_fact_source() { assert_eq!( SlashCommand::ALL.map(SlashCommand::command), - ["model", "effort", "permissions", "status", "copy"] + [ + "model", + "plan", + "effort", + "permissions", + "status", + "copy", + "agents", + "subagents", + "resume", + ] ); for command in SlashCommand::ALL { let prompt = format!("/{}", command.command()); diff --git a/lime-rs/crates/tui/src/status_indicator.rs b/lime-rs/crates/tui/src/status_indicator.rs index ef1334263..ac957f816 100644 --- a/lime-rs/crates/tui/src/status_indicator.rs +++ b/lime-rs/crates/tui/src/status_indicator.rs @@ -1,10 +1,10 @@ use std::time::Duration; -use ratatui::Frame; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::Paragraph; +use ratatui::Frame; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; @@ -45,8 +45,8 @@ fn fmt_elapsed_compact(elapsed_secs: u64) -> String { mod tests { use super::*; use crate::line_truncation::line_width; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::Terminal; fn line_text(line: &Line<'_>) -> String { line.spans diff --git a/lime-rs/crates/tui/src/status_indicator_widget.rs b/lime-rs/crates/tui/src/status_indicator_widget.rs index 899369136..7442e1ad5 100644 --- a/lime-rs/crates/tui/src/status_indicator_widget.rs +++ b/lime-rs/crates/tui/src/status_indicator_widget.rs @@ -1,7 +1,7 @@ use std::time::Duration; -use ratatui::Frame; use ratatui::layout::Rect; +use ratatui::Frame; use crate::locale::Locale; use crate::status_indicator; diff --git a/lime-rs/crates/tui/src/terminal_hyperlinks/paragraph.rs b/lime-rs/crates/tui/src/terminal_hyperlinks/paragraph.rs index 8bc256268..1c7e65805 100644 --- a/lime-rs/crates/tui/src/terminal_hyperlinks/paragraph.rs +++ b/lime-rs/crates/tui/src/terminal_hyperlinks/paragraph.rs @@ -1,6 +1,6 @@ //! Paragraph rendering that keeps visible text and hyperlink annotations aligned. -use super::{HyperlinkLine, mark_buffer_hyperlinks, visible_lines_ref}; +use super::{mark_buffer_hyperlinks, visible_lines_ref, HyperlinkLine}; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::text::Text; diff --git a/lime-rs/crates/tui/src/thread_transcript.rs b/lime-rs/crates/tui/src/thread_transcript.rs index 0d718dc05..60108a8f2 100644 --- a/lime-rs/crates/tui/src/thread_transcript.rs +++ b/lime-rs/crates/tui/src/thread_transcript.rs @@ -8,7 +8,7 @@ use std::io; use app_server_client::RequestHandle; use app_server_protocol::protocol::v2::{ - METHOD_THREAD_READ, Thread, ThreadReadParams, ThreadReadResponse, + Thread, ThreadReadParams, ThreadReadResponse, METHOD_THREAD_READ, }; use crate::app_server_session::AppServerSession; @@ -20,7 +20,7 @@ pub(crate) async fn load_session_transcript( thread_id: impl Into, ) -> io::Result> { let response = app_server - .read_thread(thread_id, true) + .thread_read(thread_id, true) .await .map_err(io::Error::other)?; Ok(thread_to_transcript_entries(response.thread)) diff --git a/lime-rs/crates/tui/src/tui.rs b/lime-rs/crates/tui/src/tui.rs index 8001bac1f..49a828af4 100644 --- a/lime-rs/crates/tui/src/tui.rs +++ b/lime-rs/crates/tui/src/tui.rs @@ -1,19 +1,23 @@ -use std::io::{self, Stdout, stdout}; +use std::future::Future; +use std::io::{self, stdout, Stdout}; use std::panic; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::sync::Once; -use std::sync::atomic::{AtomicBool, Ordering}; use crossterm::cursor::Show; use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste, KeyEvent}; use crossterm::execute; use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, }; -use ratatui::Terminal; use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Position, Size}; +use ratatui::Terminal as RatatuiTerminal; use tokio::sync::broadcast; +use crate::viewport::ViewportState; + pub(crate) mod event_stream; mod frame_rate_limiter; mod frame_requester; @@ -34,7 +38,7 @@ pub enum TuiEvent { FocusLost, } -pub(crate) type TuiTerminal = Terminal>; +pub(crate) type Terminal = RatatuiTerminal>; static PANIC_HOOK: Once = Once::new(); static TERMINAL_ACTIVE: AtomicBool = AtomicBool::new(false); @@ -66,8 +70,56 @@ fn restore_terminal_state() -> io::Result<()> { } } -pub(crate) struct TerminalGuard { - terminal: TuiTerminal, +fn set_modes() -> io::Result<()> { + execute!(stdout(), EnableBracketedPaste)?; + enable_raw_mode() +} + +fn restore_keep_raw() -> io::Result<()> { + let mut output = stdout(); + let mut first_error = execute!(output, DisableBracketedPaste).err(); + if let Err(error) = execute!(output, Show) { + first_error.get_or_insert(error); + } + match first_error { + Some(error) => Err(error), + None => Ok(()), + } +} + +#[cfg(unix)] +fn flush_terminal_input_buffer() { + // SAFETY: flushing the stdin input queue does not transfer ownership. + let result = unsafe { libc::tcflush(libc::STDIN_FILENO, libc::TCIFLUSH) }; + if result != 0 { + tracing::warn!( + error = %io::Error::last_os_error(), + "failed to flush terminal input buffer" + ); + } +} + +#[cfg(windows)] +fn flush_terminal_input_buffer() { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::System::Console::{ + FlushConsoleInputBuffer, GetStdHandle, STD_INPUT_HANDLE, + }; + + unsafe { + let handle = GetStdHandle(STD_INPUT_HANDLE); + if handle != 0 && handle != INVALID_HANDLE_VALUE && FlushConsoleInputBuffer(handle) == 0 { + tracing::warn!("failed to flush terminal input buffer"); + } + } +} + +#[cfg(not(any(unix, windows)))] +fn flush_terminal_input_buffer() {} + +pub(crate) struct Tui { + terminal: Terminal, + viewport: ViewportState, restored: bool, event_broker: Arc, draw_tx: broadcast::Sender<()>, @@ -75,7 +127,7 @@ pub(crate) struct TerminalGuard { terminal_focused: Arc, } -impl TerminalGuard { +impl Tui { pub(crate) fn enter() -> io::Result { install_panic_hook(); enable_raw_mode()?; @@ -84,7 +136,7 @@ impl TerminalGuard { let _ = disable_raw_mode(); return Err(error); } - let terminal = match Terminal::new(CrosstermBackend::new(output)) { + let terminal = match RatatuiTerminal::new(CrosstermBackend::new(output)) { Ok(terminal) => terminal, Err(error) => { let mut output = stdout(); @@ -93,6 +145,9 @@ impl TerminalGuard { return Err(error); } }; + let size = terminal.size()?; + let mut viewport = ViewportState::new(size, Position::new(0, 0)); + viewport.enter_alternate_screen(size); let event_broker = Arc::new(EventBroker::new()); let (draw_tx, _) = broadcast::channel(8); let frame_requester = FrameRequester::new(draw_tx.clone()); @@ -100,6 +155,7 @@ impl TerminalGuard { TERMINAL_ACTIVE.store(true, Ordering::Release); Ok(Self { terminal, + viewport, restored: false, event_broker, draw_tx, @@ -108,10 +164,25 @@ impl TerminalGuard { }) } - pub(crate) fn terminal_mut(&mut self) -> &mut TuiTerminal { + pub(crate) fn terminal_mut(&mut self) -> &mut Terminal { &mut self.terminal } + pub(crate) fn update_viewport(&mut self, screen_size: Size, content_height: u16) { + self.viewport.update_inline(screen_size, content_height); + } + + pub(crate) fn sync_viewport(&mut self) -> io::Result<()> { + let screen_size = self.terminal.size()?; + self.update_viewport(screen_size, screen_size.height); + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn viewport_area(&self) -> ratatui::layout::Rect { + self.viewport.area() + } + pub(crate) fn event_stream(&self) -> TuiEventStream { TuiEventStream::new( self.event_broker.clone(), @@ -138,46 +209,41 @@ impl TerminalGuard { .load(std::sync::atomic::Ordering::Relaxed) } - pub(crate) fn suspend(&mut self) -> io::Result<()> { - if self.restored { - return Ok(()); - } + /// Temporarily restore terminal state while an external interactive program runs. + pub(crate) async fn with_restored(&mut self, f: F) -> R + where + F: FnOnce() -> Fut, + Fut: Future, + { self.pause_events(); - let result = (|| { - self.terminal.show_cursor()?; - execute!( - self.terminal.backend_mut(), - DisableBracketedPaste, - LeaveAlternateScreen - )?; - disable_raw_mode()?; - TERMINAL_ACTIVE.store(false, Ordering::Release); - Ok(()) - })(); - if result.is_err() { - self.resume_events(); - } - result - } - pub(crate) fn resume(&mut self) -> io::Result<()> { - if self.restored { - return Ok(()); + let was_alt_screen = self.viewport.is_alt_screen_active(); + if was_alt_screen { + let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen); + self.viewport.leave_alternate_screen(); } - enable_raw_mode()?; - if let Err(error) = execute!( - self.terminal.backend_mut(), - EnterAlternateScreen, - EnableBracketedPaste - ) { - let _ = disable_raw_mode(); - return Err(error); + + if let Err(error) = restore_keep_raw() { + tracing::warn!(%error, "failed to restore terminal modes before external program"); } - // Re-entering the alternate screen restores the previous surface; the next draw - // reconciles the current frame without synchronously querying stdin. - self.event_broker.resume_events(); - TERMINAL_ACTIVE.store(true, Ordering::Release); - Ok(()) + + let output = f().await; + + if let Err(error) = set_modes() { + tracing::warn!(%error, "failed to re-enable terminal modes after external program"); + } + flush_terminal_input_buffer(); + + if was_alt_screen { + let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen); + if let Ok(size) = self.terminal.size() { + self.viewport.enter_alternate_screen(size); + } + } + + self.resume_events(); + self.frame_requester.schedule_frame(); + output } pub(crate) fn restore(&mut self) -> io::Result<()> { @@ -195,6 +261,7 @@ impl TerminalGuard { ) { first_error.get_or_insert(error); } + self.viewport.leave_alternate_screen(); if let Err(error) = disable_raw_mode() { first_error.get_or_insert(error); } @@ -203,9 +270,23 @@ impl TerminalGuard { None => Ok(()), } } + + #[allow(dead_code)] + pub(crate) fn enter_alternate_screen(&mut self) -> io::Result<()> { + execute!(self.terminal.backend_mut(), EnterAlternateScreen)?; + self.viewport.enter_alternate_screen(self.terminal.size()?); + self.terminal.clear() + } + + #[allow(dead_code)] + pub(crate) fn leave_alternate_screen(&mut self) -> io::Result<()> { + execute!(self.terminal.backend_mut(), LeaveAlternateScreen)?; + self.viewport.leave_alternate_screen(); + self.terminal.clear() + } } -impl Drop for TerminalGuard { +impl Drop for Tui { fn drop(&mut self) { let _ = self.restore(); } diff --git a/lime-rs/crates/tui/src/tui/event_stream.rs b/lime-rs/crates/tui/src/tui/event_stream.rs index 67551abc6..525280d86 100644 --- a/lime-rs/crates/tui/src/tui/event_stream.rs +++ b/lime-rs/crates/tui/src/tui/event_stream.rs @@ -18,20 +18,20 @@ //! See https://ratatui.rs/recipes/apps/spawn-vim/ and https://www.reddit.com/r/rust/comments/1f3o33u/myterious_crossterm_input_after_running_vim for more details. use std::pin::Pin; -use std::sync::Arc; -use std::sync::Mutex; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::sync::Mutex; use std::task::Context; use std::task::Poll; use crossterm::event::Event; use tokio::sync::broadcast; use tokio::sync::watch; -use tokio_stream::Stream; +use tokio_stream::wrappers::errors::BroadcastStreamRecvError; use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::WatchStream; -use tokio_stream::wrappers::errors::BroadcastStreamRecvError; +use tokio_stream::Stream; use super::TuiEvent; diff --git a/lime-rs/crates/tui/src/view.rs b/lime-rs/crates/tui/src/view.rs index 91b97e4e9..c46df663d 100644 --- a/lime-rs/crates/tui/src/view.rs +++ b/lime-rs/crates/tui/src/view.rs @@ -1,8 +1,8 @@ -use ratatui::Frame; use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, FrameExt as _, Paragraph}; +use ratatui::Frame; use crate::app::App; use crate::bottom_pane; @@ -13,11 +13,16 @@ use crate::model_picker; use crate::pending_input_preview; use crate::status_indicator_widget; use crate::terminal_hyperlinks::HyperlinkParagraph; -use crate::width::{display_width, usable_content_width_u16}; +use crate::width::usable_content_width_u16; use std::time::Instant; pub(crate) fn render(frame: &mut Frame<'_>, app: &App) { let area = frame.area(); + if let Some(picker) = app.resume_picker.as_ref() { + frame.render_widget(Clear, area); + crate::resume_picker::render_with_locale(frame, picker, app.locale); + return; + } if let Some(pager) = app.pager_overlay.as_ref() { let transcript_lines = if pager.is_transcript() { projected_transcript_lines(app, area.width, true) @@ -51,6 +56,14 @@ pub(crate) fn render(frame: &mut Frame<'_>, app: &App) { if let Some(picker) = app.model_picker.as_ref() { model_picker::render_with_locale(frame, area, picker, app.locale); } + if let Some(overview) = app.agents_overview.as_ref() { + crate::app::agents_overview_view::render(frame, area, &overview.view, app.locale); + } + if let Some(picker) = app.agent_picker.as_ref() { + if app.agents_overview.is_none() { + crate::app::agent_picker::render(frame, area, picker, app.locale); + } + } } fn screen_chunks(area: Rect, app: &App) -> [Rect; 6] { @@ -68,13 +81,10 @@ fn screen_chunks(area: Rect, app: &App) -> [Rect; 6] { } else { let desired = app .composer - .text() - .lines() - .count() - .max(1) - .saturating_add(app.pending_images.len()) + .desired_height(area.width.saturating_sub(2)) + .saturating_add(u16::try_from(app.composer.pending_image_count()).unwrap_or(u16::MAX)) .saturating_add(2) - .clamp(3, 12) as u16; + .clamp(3, 12); desired.min( area.height .saturating_sub(preview_height) @@ -178,8 +188,11 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { .borders(Borders::TOP | Borders::BOTTOM) .border_style(Style::default().fg(Color::DarkGray)); let inner = block.inner(area); - let mut lines = app - .pending_images + frame.render_widget(block, area); + + let image_lines = app + .composer + .pending_images() .iter() .enumerate() .map(|(index, _)| { @@ -189,35 +202,37 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { ) }) .collect::>(); - lines.extend(app.composer.text().split('\n').map(Line::raw)); - frame.render_widget( - Paragraph::new(lines) - .block(block) - .wrap(Wrap { trim: false }), - area, - ); - if inner.width == 0 || inner.height == 0 { return; } - let before_cursor = &app.composer.text()[..app.composer.cursor()]; - let row = app - .pending_images - .len() - .saturating_add(before_cursor.chars().filter(|ch| *ch == '\n').count()); - let column_text = before_cursor.rsplit('\n').next().unwrap_or(""); - let column = display_width(column_text); - let x = inner.x.saturating_add( - u16::try_from(column) - .unwrap_or(u16::MAX) - .min(inner.width.saturating_sub(1)), + + let image_height = u16::try_from(image_lines.len()) + .unwrap_or(u16::MAX) + .min(inner.height); + if image_height > 0 { + let image_area = Rect::new(inner.x, inner.y, inner.width, image_height); + frame.render_widget(Paragraph::new(image_lines), image_area); + } + + let text_area = Rect::new( + inner.x, + inner.y.saturating_add(image_height), + inner.width, + inner.height.saturating_sub(image_height), ); - let y = inner.y.saturating_add( - u16::try_from(row) - .unwrap_or(u16::MAX) - .min(inner.height.saturating_sub(1)), - ); - frame.set_cursor_position(Position::new(x, y)); + if text_area.is_empty() { + return; + } + let cursor = { + let mut state = app.composer.textarea_state_mut(); + frame.render_stateful_widget_ref(app.composer.textarea(), text_area, &mut *state); + app.composer + .textarea() + .cursor_pos_with_state(text_area, *state) + }; + if let Some((x, y)) = cursor { + frame.set_cursor_position(Position::new(x, y)); + } } fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { @@ -238,9 +253,17 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { .active_turn_id() .map(|turn| format!(" {} {turn}", app.locale.turn_label())) .unwrap_or_default(); + let active_agent = app + .agent_navigation + .active_agent_label(app.thread_id.as_deref(), app.primary_thread_id.as_deref()) + .map(|label| format!(" {label}")) + .unwrap_or_default(); let width = usable_content_width_u16(area.width, 1).unwrap_or_default(); let line = truncate_line_with_ellipsis_if_overflow( - Line::styled(format!(" {active}"), Style::default().fg(Color::DarkGray)), + Line::styled( + format!(" {active}{active_agent}"), + Style::default().fg(Color::DarkGray), + ), width, ); frame.render_widget(Paragraph::new(line), area); @@ -257,7 +280,6 @@ fn status_text(app: &App) -> String { mod tests { use super::*; use crate::locale::Locale; - use app_server_protocol::RequestId; use app_server_protocol::protocol::v2::{ AgentMessageDeltaNotification, CommandExecutionOutputDeltaNotification, CommandExecutionRequestApprovalParams, CommandExecutionSource, FileUpdateChange, @@ -266,9 +288,25 @@ mod tests { ToolRequestUserInputParams, ToolRequestUserInputQuestion, TurnDiffUpdatedNotification, TurnPlanStep, TurnPlanStepStatus, TurnPlanUpdatedNotification, UserInput, }; + use app_server_protocol::RequestId; use crossterm::event::Event; - use ratatui::Terminal; use ratatui::backend::TestBackend; + use ratatui::widgets::Wrap; + use ratatui::Terminal; + + fn dispatch_connected_input(app: &mut App, event: Event) -> crate::app::AppAction { + let event = match event { + Event::Key(key) => crate::tui::TuiEvent::Key(key), + Event::Paste(text) => crate::tui::TuiEvent::Paste(text), + Event::Resize(width, height) => { + crate::tui::TuiEvent::Resize(ratatui::layout::Size { width, height }) + } + Event::FocusGained => crate::tui::TuiEvent::FocusGained, + Event::FocusLost => crate::tui::TuiEvent::FocusLost, + _ => crate::tui::TuiEvent::Draw, + }; + app.handle_tui_event(event, true) + } #[test] fn wrapped_transcript_scroll_uses_visual_rows() { @@ -416,6 +454,23 @@ mod tests { ); } + #[test] + fn footer_renders_the_active_agent_label() { + let mut app = App::default(); + app.set_thread_id("main".to_string()); + app.agent_navigation.upsert( + "agent-1", + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + app.set_thread_id("agent-1".to_string()); + + let mut terminal = Terminal::new(TestBackend::new(64, 8)).expect("terminal"); + terminal.draw(|frame| render(frame, &app)).expect("draw"); + assert!(buffer_text(&terminal).contains("Robie [explorer]")); + } + #[test] fn active_turn_status_does_not_overflow_a_tiny_terminal() { let mut app = App::default(); @@ -445,15 +500,21 @@ mod tests { app.set_locale(Locale::ZhCn); app.composer.load_history(["git status".to_string()]); app.composer.insert("git"); - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('r'), - crossterm::event::KeyModifiers::CONTROL, - ))); + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('r'), + crossterm::event::KeyModifiers::CONTROL, + )), + ); for character in "git".chars() { - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char(character), - crossterm::event::KeyModifiers::NONE, - ))); + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )), + ); } let mut terminal = Terminal::new(TestBackend::new(48, 8)).expect("terminal"); @@ -472,11 +533,14 @@ mod tests { fn test_backend_renders_filtered_slash_command_popup_above_composer() { let mut app = App::default(); app.set_locale(Locale::ZhCn); - for character in ['/', 'p'] { - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char(character), - crossterm::event::KeyModifiers::NONE, - ))); + for character in ['/', 'p', 'e'] { + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + )), + ); } let mut terminal = Terminal::new(TestBackend::new(48, 10)).expect("terminal"); @@ -490,7 +554,7 @@ mod tests { assert!(text.contains("> /permissions")); assert!(compact.contains("设置权限配置"), "{text}"); assert!(!text.contains("/model")); - assert!(text.contains("/p")); + assert!(text.contains("/pe")); } #[test] @@ -505,10 +569,13 @@ mod tests { Some(":workspace".to_string()), ); app.composer.insert("/status"); - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Enter, - crossterm::event::KeyModifiers::NONE, - ))); + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + )), + ); let mut terminal = Terminal::new(TestBackend::new(72, 12)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("draw"); @@ -541,10 +608,13 @@ mod tests { delta: format!("**first** [link]({destination})"), }, )); - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('t'), - crossterm::event::KeyModifiers::CONTROL, - ))); + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('t'), + crossterm::event::KeyModifiers::CONTROL, + )), + ); let mut terminal = Terminal::new(TestBackend::new(48, 9)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("draw"); @@ -896,10 +966,13 @@ mod tests { #[test] fn approval_hides_an_open_slash_command_popup() { let mut app = App::default(); - app.handle_terminal_event(Event::Key(crossterm::event::KeyEvent::new( - crossterm::event::KeyCode::Char('/'), - crossterm::event::KeyModifiers::NONE, - ))); + dispatch_connected_input( + &mut app, + Event::Key(crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Char('/'), + crossterm::event::KeyModifiers::NONE, + )), + ); app.bottom_pane .enqueue(ServerRequest::ItemCommandExecutionRequestApproval { id: RequestId::Integer(9), @@ -949,7 +1022,7 @@ mod tests { }, }) .expect("queue user input"); - app.handle_terminal_event(Event::Paste("sensitive".to_string())); + dispatch_connected_input(&mut app, Event::Paste("sensitive".to_string())); let mut terminal = Terminal::new(TestBackend::new(40, 10)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("draw"); diff --git a/lime-rs/crates/tui/src/viewport.rs b/lime-rs/crates/tui/src/viewport.rs new file mode 100644 index 000000000..02185c7d0 --- /dev/null +++ b/lime-rs/crates/tui/src/viewport.rs @@ -0,0 +1,201 @@ +//! Codex-shaped viewport state for the inline TUI surface. +//! +//! The full Codex terminal owns scrollback and buffer diffing in +//! `custom_terminal::Terminal`. Lime currently keeps ratatui's terminal as its renderer, so this +//! module owns the same viewport geometry and cursor anchoring contract without introducing a +//! second renderer. The runtime can use it to keep the composer area stable as the terminal is +//! resized or an alternate screen is entered. + +use ratatui::layout::{Position, Rect, Size}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ViewportState { + viewport_area: Rect, + last_known_screen_size: Size, + last_known_cursor_pos: Position, + alt_saved_viewport: Option, + alt_screen_active: bool, + viewport_invalidated: bool, +} + +impl ViewportState { + pub(crate) fn new(screen_size: Size, cursor_position: Position) -> Self { + Self { + viewport_area: Rect::new(0, cursor_position.y, screen_size.width, 0), + last_known_screen_size: screen_size, + last_known_cursor_pos: cursor_position, + alt_saved_viewport: None, + alt_screen_active: false, + viewport_invalidated: false, + } + } + + pub(crate) const fn area(&self) -> Rect { + self.viewport_area + } + + #[allow(dead_code)] + pub(crate) const fn is_alt_screen_active(&self) -> bool { + self.alt_screen_active + } + + #[allow(dead_code)] + pub(crate) fn set_viewport_area(&mut self, area: Rect) { + self.viewport_area = area; + } + + #[allow(dead_code)] + pub(crate) fn resize(&mut self, screen_size: Size) { + let height = self.viewport_area.height.min(screen_size.height); + let bottom_aligned = self.viewport_area.bottom() == self.last_known_screen_size.height; + let mut area = self.viewport_area; + area.width = screen_size.width; + area.height = height; + if area.bottom() > screen_size.height + || (bottom_aligned && screen_size.height > self.last_known_screen_size.height) + { + area.y = screen_size.height.saturating_sub(height); + } + self.last_known_screen_size = screen_size; + self.viewport_area = area; + } + + pub(crate) fn update_inline(&mut self, screen_size: Size, content_height: u16) -> Rect { + let height = content_height.min(screen_size.height); + let was_bottom_aligned = self.viewport_area.bottom() == self.last_known_screen_size.height; + let mut area = self.viewport_area; + area.width = screen_size.width; + area.height = height; + if area.bottom() > screen_size.height + || (screen_size.height > self.last_known_screen_size.height && was_bottom_aligned) + { + area.y = screen_size.height.saturating_sub(height); + } + self.last_known_screen_size = screen_size; + self.viewport_area = area; + area + } + + #[allow(dead_code)] + pub(crate) fn update_cursor_anchor(&mut self, cursor_position: Position) { + self.last_known_cursor_pos = cursor_position; + } + + #[allow(dead_code)] + pub(crate) fn cursor_anchor(&self) -> Position { + self.last_known_cursor_pos + } + + #[allow(dead_code)] + pub(crate) const fn is_viewport_invalidated(&self) -> bool { + self.viewport_invalidated + } + + #[allow(dead_code)] + pub(crate) fn invalidate_viewport(&mut self) { + self.viewport_invalidated = true; + } + + #[allow(dead_code)] + pub(crate) fn clear_for_viewport_change(&self, new_area: Rect) -> Position { + if self.viewport_area.is_empty() { + new_area.as_position() + } else { + self.viewport_area.as_position() + } + } + + pub(crate) fn enter_alternate_screen(&mut self, screen_size: Size) -> Rect { + if !self.alt_screen_active { + self.alt_saved_viewport = Some(self.viewport_area); + self.alt_screen_active = true; + } + self.viewport_area = Rect::new(0, 0, screen_size.width, screen_size.height); + self.last_known_screen_size = screen_size; + self.viewport_area + } + + pub(crate) fn leave_alternate_screen(&mut self) -> Rect { + self.alt_screen_active = false; + if let Some(area) = self.alt_saved_viewport.take() { + self.viewport_area = area; + } + self.viewport_area + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn inline_viewport_stays_bottom_aligned_when_terminal_grows() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 20)); + state.update_inline(Size::new(80, 24), 4); + let area = state.update_inline(Size::new(80, 30), 4); + assert_eq!(area, Rect::new(0, 26, 80, 4)); + } + + #[test] + fn inline_viewport_shrinks_into_terminal_bounds() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 20)); + state.update_inline(Size::new(80, 24), 8); + let area = state.update_inline(Size::new(80, 6), 8); + assert_eq!(area, Rect::new(0, 0, 80, 6)); + } + + #[test] + fn alternate_screen_round_trip_restores_inline_viewport() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 20)); + state.update_inline(Size::new(80, 24), 4); + assert_eq!( + state.enter_alternate_screen(Size::new(100, 40)), + Rect::new(0, 0, 100, 40) + ); + assert!(state.is_alt_screen_active()); + assert_eq!(state.leave_alternate_screen(), Rect::new(0, 20, 80, 4)); + assert!(!state.is_alt_screen_active()); + } + + #[test] + fn repeated_alternate_entry_preserves_original_inline_area() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 20)); + state.update_inline(Size::new(80, 24), 4); + state.enter_alternate_screen(Size::new(100, 40)); + state.enter_alternate_screen(Size::new(120, 50)); + assert_eq!(state.leave_alternate_screen(), Rect::new(0, 20, 80, 4)); + } + + #[test] + fn resize_keeps_non_bottom_aligned_viewport_position() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 5)); + state.set_viewport_area(Rect::new(0, 5, 80, 4)); + state.resize(Size::new(100, 30)); + assert_eq!(state.area(), Rect::new(0, 5, 100, 4)); + } + + #[test] + fn leaving_alternate_without_saved_area_is_a_noop() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 20)); + let original = state.area(); + assert_eq!(state.leave_alternate_screen(), original); + assert!(!state.is_alt_screen_active()); + } + + #[test] + fn first_viewport_change_clears_from_new_area_when_old_area_is_empty() { + let state = ViewportState::new(Size::new(80, 24), Position::new(0, 3)); + assert_eq!( + state.clear_for_viewport_change(Rect::new(0, 4, 80, 6)), + Position::new(0, 4) + ); + } + + #[test] + fn invalidate_viewport_sets_repaint_marker() { + let mut state = ViewportState::new(Size::new(80, 24), Position::new(0, 3)); + assert!(!state.is_viewport_invalidated()); + state.invalidate_viewport(); + assert!(state.is_viewport_invalidated()); + } +} diff --git a/lime-rs/crates/tui/src/wrapping.rs b/lime-rs/crates/tui/src/wrapping.rs index 1dcee2886..b8d90f8cc 100644 --- a/lime-rs/crates/tui/src/wrapping.rs +++ b/lime-rs/crates/tui/src/wrapping.rs @@ -531,11 +531,9 @@ mod tests { #[test] fn url_detection_rejects_file_paths() { - assert!( - text_contains_url_like("open https://example.com") - .then_some(()) - .is_some() - ); + assert!(text_contains_url_like("open https://example.com") + .then_some(()) + .is_some()); assert!(!text_contains_url_like("src/main.rs foo/bar")); } @@ -550,11 +548,9 @@ mod tests { .collect::>(), vec!["你好", "世界"] ); - assert!( - wrap_ranges(text, Options::new(5)) - .iter() - .all(|range| range.end <= text.len() + 1) - ); + assert!(wrap_ranges(text, Options::new(5)) + .iter() + .all(|range| range.end <= text.len() + 1)); } #[test] diff --git a/package.json b/package.json index e6c3a2645..91b5a8d1b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": [ "packages/*" ], - "version": "1.141.0", + "version": "1.142.0", "description": "Open-source full-stack desktop AI agent for global users: coding, research, content, files, terminals, tools, multimodal work, and multi-agent workflows.", "keywords": [ "ai", diff --git a/packages/app-server-client/README.md b/packages/app-server-client/README.md index 4a050fdf4..7088059d1 100644 --- a/packages/app-server-client/README.md +++ b/packages/app-server-client/README.md @@ -14,6 +14,7 @@ Current scope: - build sidecar launch config from manifest + resources path; - verify the sidecar binary sha256 before launch; - spawn / connect a stdio sidecar with the initialize handshake; +- connect an authenticated remote WebSocket with the same initialize handshake; - supervise sidecar crash, startup failure, and restart with deterministic backoff; - route direct v2 Thread / Turn / Item lifecycle notifications into app-owned state, including `item/agentMessage/delta`; @@ -98,7 +99,6 @@ const thread = await runtime.readThread({ threadId: session.result.thread.id, includeTurns: true, }); - ``` `AgentRuntimeClient` is a facade over the current App Server JSON-RPC methods. @@ -122,3 +122,30 @@ evidence export surface is not available on this client. Sidecar `backendMode: "mock"` is test-only. Production hosts must use `runtime`, `external`, or fail closed; they must not treat the mock backend as a fallback for Agent Runtime or renderer UI flows. + +Remote WebSocket foundation: + +```ts +import { + connectRemoteAppServer, + type RemoteTransportConfig, +} from "@limecloud/app-server-client"; + +const remote: RemoteTransportConfig = { + websocketUrl: "wss://cloud.example/app-server", + authToken: process.env.LIME_REMOTE_TOKEN, + tenantId: "tenant-42", +}; +const connected = await connectRemoteAppServer(remote, { + clientInfo: { name: "host-app", version: "1" }, +}); +``` + +The remote transport sends credentials only as `Authorization: Bearer ...` and +the optional `X-Lime-Tenant-ID` header. It rejects URL userinfo, fragments, +credential query parameters, insecure public `ws://` token connections, binary +frames, and messages above 128 MiB. The server must identify itself as +`app-server` and speak `appserver.v0` before `initialized` is sent. This is a +transport foundation only; tenant isolation, token storage/rotation, reconnect, +rate limiting, audit, and a production Cloud endpoint remain outside this +package and must be completed by the Cloud service before enablement. diff --git a/packages/app-server-client/package.json b/packages/app-server-client/package.json index 4f1f97d5c..31519b9b1 100644 --- a/packages/app-server-client/package.json +++ b/packages/app-server-client/package.json @@ -35,6 +35,9 @@ "dist", "README.md" ], + "dependencies": { + "ws": "^8.18.3" + }, "scripts": { "build": "tsc --project tsconfig.json", "typecheck": "tsc --noEmit --project tsconfig.json", diff --git a/packages/app-server-client/src/index.ts b/packages/app-server-client/src/index.ts index 854c837e3..338a13cb0 100644 --- a/packages/app-server-client/src/index.ts +++ b/packages/app-server-client/src/index.ts @@ -6,4 +6,5 @@ export * from "./connection.js"; export * from "./agent-runtime.js"; export * from "./server-notifications.js"; export * from "./model-route.js"; +export * from "./remote.js"; export * from "./sidecar.js"; diff --git a/packages/app-server-client/src/remote.ts b/packages/app-server-client/src/remote.ts new file mode 100644 index 000000000..0401a332a --- /dev/null +++ b/packages/app-server-client/src/remote.ts @@ -0,0 +1,559 @@ +/// + +import { isIP } from "node:net"; + +import WebSocket from "ws"; + +import * as protocol from "./protocol.js"; +import { + AppServerConnection, + type AppServerMessageTransport, +} from "./connection.js"; +import { AppServerClient } from "./request-client.js"; + +export const DEFAULT_REMOTE_CONNECT_TIMEOUT_MS = 10_000; +export const DEFAULT_REMOTE_MESSAGE_TIMEOUT_MS = 30_000; +export const MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES = 128 * 1024 * 1024; + +export type RemoteTransportConfig = { + websocketUrl: string; + authToken?: string; + tenantId?: string; + expectedServerName?: string; + maxMessageBytes?: number; + connectTimeoutMs?: number; +}; + +export type ConnectRemoteAppServerOptions = { + client?: AppServerClient; + initializeTimeoutMs?: number; + expectedProtocolVersion?: string; +}; + +export type ConnectedRemoteAppServer = { + client: AppServerClient; + connection: AppServerConnection; + transport: RemoteTransport; + initializeResponse: protocol.InitializeResponse; +}; + +type PendingMessageRead = { + resolve: (message: protocol.JsonRpcMessage) => void; + reject: (error: unknown) => void; + timer?: ReturnType; +}; + +/** + * Authenticated WebSocket transport for the same JSON-RPC session used by + * local stdio. It deliberately contains no runtime or business routing. + */ +export class RemoteTransport implements AppServerMessageTransport { + readonly websocketUrl: string; + readonly maxMessageBytes: number; + + readonly #socket: WebSocket; + + #messages: protocol.JsonRpcMessage[] = []; + #waiters: PendingMessageRead[] = []; + #closed = false; + #closedError: Error | null = null; + + private constructor( + socket: WebSocket, + websocketUrl: string, + maxMessageBytes: number, + ) { + this.#socket = socket; + this.websocketUrl = websocketUrl; + this.maxMessageBytes = maxMessageBytes; + + socket.on("message", (data, isBinary) => + this.#receiveMessage(data, isBinary), + ); + socket.on("error", (error) => this.#markClosed(error)); + socket.on("close", (code, reason) => { + const detail = reason.toString("utf8"); + this.#markClosed( + new Error( + detail.length > 0 + ? `remote App Server WebSocket closed (${code}): ${detail}` + : `remote App Server WebSocket closed (${code})`, + ), + ); + }); + } + + static async connect( + config: RemoteTransportConfig, + ): Promise { + const validated = validateRemoteTransportConfig(config); + const socket = await openWebSocket( + validated.url, + { + headers: validated.headers, + maxPayload: validated.maxMessageBytes, + perMessageDeflate: false, + }, + validated.connectTimeoutMs, + ); + return new RemoteTransport( + socket, + validated.url.toString(), + validated.maxMessageBytes, + ); + } + + send(message: protocol.JsonRpcMessage): void { + if (this.#closed || this.#socket.readyState !== WebSocket.OPEN) { + throw ( + this.#closedError ?? new Error("remote App Server WebSocket is closed") + ); + } + + const encoded = protocol.encodeMessage(message); + if (byteLength(encoded) > this.maxMessageBytes) { + throw new Error( + `remote App Server message exceeds ${this.maxMessageBytes} bytes`, + ); + } + + try { + this.#socket.send(encoded, (error) => { + if (error) { + this.#markClosed(error); + } + }); + } catch (error) { + this.#markClosed( + toError(error, "remote App Server WebSocket send failed"), + ); + throw this.#closedError; + } + } + + nextMessage( + timeoutMs = DEFAULT_REMOTE_MESSAGE_TIMEOUT_MS, + ): Promise { + const message = this.#messages.shift(); + if (message) { + return Promise.resolve(message); + } + if (this.#closed) { + return Promise.reject( + this.#closedError ?? new Error("remote App Server WebSocket is closed"), + ); + } + if (timeoutMs <= 0) { + return Promise.reject( + new Error( + `timed out waiting for app-server message after ${timeoutMs}ms`, + ), + ); + } + + return new Promise((resolve, reject) => { + const pending: PendingMessageRead = { resolve, reject }; + pending.timer = setTimeout(() => { + this.#waiters = this.#waiters.filter((waiter) => waiter !== pending); + reject( + new Error( + `timed out waiting for app-server message after ${timeoutMs}ms`, + ), + ); + }, timeoutMs); + this.#waiters.push(pending); + }); + } + + async close(timeoutMs = 5_000): Promise { + if (this.#socket.readyState === WebSocket.CLOSED) { + this.#markClosed( + this.#closedError ?? new Error("remote App Server WebSocket is closed"), + ); + return; + } + + await new Promise((resolve) => { + let timer: ReturnType | undefined; + const finish = () => { + if (timer) { + clearTimeout(timer); + } + this.#socket.removeListener("close", finish); + resolve(); + }; + this.#socket.once("close", finish); + timer = setTimeout( + () => { + this.#socket.terminate(); + finish(); + }, + Math.max(0, timeoutMs), + ); + + if (this.#socket.readyState === WebSocket.CONNECTING) { + this.#socket.terminate(); + } else { + this.#socket.close(); + } + }); + } + + #receiveMessage( + data: string | ArrayBuffer | Buffer | Buffer[], + isBinary: boolean, + ): void { + if (this.#closed) { + return; + } + if (isBinary) { + this.#markClosed( + new Error("remote App Server sent a non-text WebSocket frame"), + ); + this.#socket.terminate(); + return; + } + + const text = websocketText(data); + if (byteLength(text) > this.maxMessageBytes) { + this.#markClosed( + new Error( + `remote App Server message exceeds ${this.maxMessageBytes} bytes`, + ), + ); + this.#socket.terminate(); + return; + } + + let message: protocol.JsonRpcMessage; + try { + message = protocol.decodeMessage(text); + } catch (error) { + this.#markClosed( + toError(error, "invalid remote App Server JSON-RPC message"), + ); + this.#socket.terminate(); + return; + } + + const waiter = this.#waiters.shift(); + if (waiter) { + if (waiter.timer) { + clearTimeout(waiter.timer); + } + waiter.resolve(message); + return; + } + this.#messages.push(message); + } + + #markClosed(error: Error): void { + if (this.#closed) { + return; + } + this.#closed = true; + this.#closedError = error; + for (const waiter of this.#waiters.splice(0)) { + if (waiter.timer) { + clearTimeout(waiter.timer); + } + waiter.reject(error); + } + } +} + +export async function connectRemoteAppServer( + config: RemoteTransportConfig, + initializeParams: protocol.InitializeParams, + options: ConnectRemoteAppServerOptions = {}, +): Promise { + const client = options.client ?? new AppServerClient(); + const transport = await RemoteTransport.connect(config); + + try { + const initializeRequest = client.initialize(initializeParams); + transport.send(initializeRequest); + const initializeMessage = await transport.nextMessage( + options.initializeTimeoutMs, + ); + const initializeResponse = expectInitializeResponse( + initializeMessage, + initializeRequest.id, + ); + assertInitializeResponse( + initializeResponse, + config.expectedServerName ?? protocol.SERVER_NAME, + options.expectedProtocolVersion ?? protocol.PROTOCOL_VERSION, + ); + transport.send(client.initialized()); + + return { + client, + connection: new AppServerConnection(transport, client), + transport, + initializeResponse, + }; + } catch (error) { + await transport.close().catch(() => undefined); + throw error; + } +} + +function validateRemoteTransportConfig(config: RemoteTransportConfig): { + url: URL; + headers: Record; + maxMessageBytes: number; + connectTimeoutMs: number; +} { + let url: URL; + try { + url = new URL(config.websocketUrl); + } catch (error) { + throw new Error( + `invalid remote App Server URL \`${config.websocketUrl}\`: ${toError(error).message}`, + ); + } + + if (url.protocol !== "ws:" && url.protocol !== "wss:") { + throw new Error( + `remote App Server URL must use \`ws://\` or \`wss://\`: ${config.websocketUrl}`, + ); + } + if (url.username || url.password || url.hash) { + throw new Error( + "remote App Server URL must not contain userinfo or a fragment", + ); + } + if (hasSensitiveQuery(url)) { + throw new Error( + "remote App Server URL must not contain credential query parameters", + ); + } + + assertSafeHeaderValue(config.authToken, "auth token"); + const authToken = config.authToken?.trim(); + if (config.authToken !== undefined && !authToken) { + throw new Error("remote App Server auth token must not be empty"); + } + if (authToken && url.protocol === "ws:" && !isLoopbackUrl(url)) { + throw new Error( + `remote auth tokens require \`wss://\` or loopback \`ws://\` URLs; got \`${config.websocketUrl}\``, + ); + } + + assertSafeHeaderValue(config.tenantId, "tenant id"); + const tenantId = config.tenantId?.trim(); + if (config.tenantId !== undefined && !tenantId) { + throw new Error("remote App Server tenant id must not be empty"); + } + const maxMessageBytes = + config.maxMessageBytes ?? MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES; + if ( + !Number.isSafeInteger(maxMessageBytes) || + maxMessageBytes <= 0 || + maxMessageBytes > MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES + ) { + throw new Error( + `remote App Server message limit must be an integer between 1 and ${MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES} bytes`, + ); + } + + const connectTimeoutMs = + config.connectTimeoutMs ?? DEFAULT_REMOTE_CONNECT_TIMEOUT_MS; + if (!Number.isFinite(connectTimeoutMs) || connectTimeoutMs <= 0) { + throw new Error( + "remote App Server connect timeout must be greater than zero", + ); + } + + const headers: Record = {}; + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; + } + if (tenantId) { + headers["X-Lime-Tenant-ID"] = tenantId; + } + + return { url, headers, maxMessageBytes, connectTimeoutMs }; +} + +async function openWebSocket( + url: URL, + options: { + headers: Record; + maxPayload: number; + perMessageDeflate: boolean; + }, + timeoutMs: number, +): Promise { + let socket: WebSocket; + try { + socket = new WebSocket(url, options); + } catch (error) { + throw toError(error, "failed to create remote App Server WebSocket"); + } + + return await new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (settled) { + return; + } + settled = true; + socket.terminate(); + reject( + new Error(`timed out connecting to remote App Server at \`${url}\``), + ); + }, timeoutMs); + const cleanup = () => { + clearTimeout(timer); + socket.removeListener("open", onOpen); + socket.removeListener("error", onError); + socket.removeListener("close", onClose); + }; + const onOpen = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(socket); + }; + const onError = (error: Error) => { + if (settled) { + return; + } + settled = true; + cleanup(); + socket.terminate(); + reject( + new Error( + `failed to connect to remote App Server at \`${url}\`: ${error.message}`, + ), + ); + }; + const onClose = (code: number, reason: Buffer) => { + if (settled) { + return; + } + settled = true; + cleanup(); + const detail = reason.toString("utf8"); + reject( + new Error( + detail.length > 0 + ? `remote App Server closed during connect (${code}): ${detail}` + : `remote App Server closed during connect (${code})`, + ), + ); + }; + socket.once("open", onOpen); + socket.once("error", onError); + socket.once("close", onClose); + }); +} + +function expectInitializeResponse( + message: protocol.JsonRpcMessage, + id: protocol.RequestId, +): protocol.InitializeResponse { + if (protocol.isJsonRpcErrorResponse(message)) { + throw new Error(`initialize failed: ${message.error.message}`); + } + if (!protocol.isJsonRpcResponse(message) || message.id !== id) { + throw new Error(`expected initialize response for request ${String(id)}`); + } + return message.result as protocol.InitializeResponse; +} + +function assertInitializeResponse( + response: protocol.InitializeResponse, + expectedServerName: string, + expectedProtocolVersion: string, +): void { + const serverInfo = response?.serverInfo; + if ( + !serverInfo || + typeof serverInfo.name !== "string" || + typeof serverInfo.protocolVersion !== "string" + ) { + throw new Error( + "remote App Server initialize response is missing serverInfo", + ); + } + if (serverInfo.name !== expectedServerName) { + throw new Error( + `remote App Server identity mismatch: expected \`${expectedServerName}\`, got \`${serverInfo.name}\``, + ); + } + if (serverInfo.protocolVersion !== expectedProtocolVersion) { + throw new Error( + `unsupported app-server protocol: expected ${expectedProtocolVersion}, got ${serverInfo.protocolVersion}`, + ); + } +} + +function hasSensitiveQuery(url: URL): boolean { + const sensitiveNames = new Set([ + "access_token", + "api_key", + "apikey", + "auth_token", + "authorization", + "bearer", + "token", + ]); + for (const key of url.searchParams.keys()) { + if (sensitiveNames.has(key.toLowerCase())) { + return true; + } + } + return false; +} + +function isLoopbackUrl(url: URL): boolean { + const hostname = url.hostname.replace(/^\[|\]$/g, "").toLowerCase(); + if (hostname === "localhost" || hostname === "localhost.") { + return true; + } + const addressType = isIP(hostname); + return addressType === 4 + ? hostname.startsWith("127.") + : addressType === 6 && hostname === "::1"; +} + +function assertSafeHeaderValue(value: string | undefined, label: string): void { + if (value && /[\r\n]/u.test(value)) { + throw new Error( + `remote App Server ${label} contains invalid header characters`, + ); + } +} + +function websocketText(data: string | ArrayBuffer | Buffer | Buffer[]): string { + if (typeof data === "string") { + return data; + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (Array.isArray(data)) { + return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString( + "utf8", + ); + } + return Buffer.from(data).toString("utf8"); +} + +function byteLength(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +function toError( + error: unknown, + fallback = "remote App Server transport failed", +): Error { + return error instanceof Error + ? error + : new Error(error ? String(error) : fallback); +} diff --git a/packages/app-server-client/src/sidecar-manifest.ts b/packages/app-server-client/src/sidecar-manifest.ts index f592d7f4a..0235a15ed 100644 --- a/packages/app-server-client/src/sidecar-manifest.ts +++ b/packages/app-server-client/src/sidecar-manifest.ts @@ -92,12 +92,13 @@ export function stdioSidecar( appPolicyPath?: string, dataDir?: string, ): SidecarLaunchConfig { + const normalizedDataDir = normalizeSidecarDataDir(dataDir); return { binaryPath, listenUrl: DEFAULT_LISTEN_URL, backendMode: DEFAULT_STANDALONE_BACKEND_MODE, ...(appPolicyPath ? { appPolicyPath } : {}), - ...(dataDir ? { dataDir } : {}), + ...(normalizedDataDir ? { dataDir: normalizedDataDir } : {}), }; } @@ -109,12 +110,13 @@ export function sidecarFromReleaseArtifact( appPolicyPath?: string, dataDir?: string, ): SidecarLaunchConfig { + const normalizedDataDir = normalizeSidecarDataDir(dataDir); return { binaryPath, listenUrl, backendMode, ...(appPolicyPath ? { appPolicyPath } : {}), - ...(dataDir ? { dataDir } : {}), + ...(normalizedDataDir ? { dataDir: normalizedDataDir } : {}), expectedSha256: artifact.sha256, artifact, }; @@ -143,8 +145,9 @@ export function sidecarArgs(config: SidecarLaunchConfig): string[] { if (config.appPolicyPath) { args.push("--app-policy", config.appPolicyPath); } - if (config.dataDir) { - args.push("--data-dir", config.dataDir); + const dataDir = normalizeSidecarDataDir(config.dataDir); + if (dataDir) { + args.push("--data-dir", dataDir); } return args; } @@ -196,6 +199,8 @@ export function resolveSidecarFromReleaseManifest( return undefined; } + const dataDir = normalizeSidecarDataDir(options.dataDir); + return { config: { binaryPath: binaryPath.binaryPath, @@ -211,7 +216,7 @@ export function resolveSidecarFromReleaseManifest( ...(options.appPolicyPath ? { appPolicyPath: options.appPolicyPath } : {}), - ...(options.dataDir ? { dataDir: options.dataDir } : {}), + ...(dataDir ? { dataDir } : {}), expectedSha256: binaryPath.source === "resources" ? artifact.sha256 : undefined, artifact, @@ -221,6 +226,31 @@ export function resolveSidecarFromReleaseManifest( }; } +/** + * A sidecar data root must be an explicit filesystem path. In particular, + * stringified optional values such as "undefined" must never become a + * relative path under the caller's working directory. + */ +function normalizeSidecarDataDir(dataDir: string | undefined): string | undefined { + if (dataDir === undefined) { + return undefined; + } + const normalized = dataDir.trim(); + if (!normalized) { + throw new Error("App Server data directory must not be empty"); + } + if (normalized === "undefined" || normalized === "null") { + throw new Error("App Server data directory is invalid"); + } + if (/[\u0000-\u001f\u007f]/u.test(normalized)) { + throw new Error("App Server data directory contains control characters"); + } + if (!path.isAbsolute(normalized) && !path.win32.isAbsolute(normalized)) { + throw new Error("App Server data directory must be an absolute path"); + } + return normalized; +} + export async function readReleaseManifest( path: string, ): Promise { diff --git a/packages/app-server-client/src/ws.d.ts b/packages/app-server-client/src/ws.d.ts new file mode 100644 index 000000000..a19489a68 --- /dev/null +++ b/packages/app-server-client/src/ws.d.ts @@ -0,0 +1,42 @@ +declare module "ws" { + type WebSocketData = string | ArrayBuffer | Buffer | Buffer[]; + + type WebSocketMessageListener = ( + data: WebSocketData, + isBinary: boolean, + ) => void; + + type WebSocketCloseListener = (code: number, reason: Buffer) => void; + + type WebSocketOptions = { + headers?: Record; + maxPayload?: number; + perMessageDeflate?: boolean; + }; + + class WebSocket { + static readonly CONNECTING: number; + static readonly OPEN: number; + static readonly CLOSING: number; + static readonly CLOSED: number; + + readonly readyState: number; + + constructor(address: string | URL, options?: WebSocketOptions); + + on(event: "open", listener: () => void): this; + on(event: "message", listener: WebSocketMessageListener): this; + on(event: "error", listener: (error: Error) => void): this; + on(event: "close", listener: WebSocketCloseListener): this; + once(event: "open", listener: () => void): this; + once(event: "error", listener: (error: Error) => void): this; + once(event: "close", listener: WebSocketCloseListener): this; + removeListener(event: string, listener: (...args: any[]) => void): this; + + send(data: string, callback?: (error?: Error) => void): void; + close(code?: number, data?: string): void; + terminate(): void; + } + + export default WebSocket; +} diff --git a/packages/app-server-client/tests/browser-entry.test.mjs b/packages/app-server-client/tests/browser-entry.test.mjs index e1b59ef38..735198fa8 100644 --- a/packages/app-server-client/tests/browser-entry.test.mjs +++ b/packages/app-server-client/tests/browser-entry.test.mjs @@ -7,4 +7,5 @@ test("browser entry exports renderer APIs without Node sidecar helpers", () => { assert.equal(typeof browserEntry.createRuntimeRequest, "function"); assert.equal("resolveSidecarBinaryPath" in browserEntry, false); assert.equal("connectAppServerSidecar" in browserEntry, false); + assert.equal("connectRemoteAppServer" in browserEntry, false); }); diff --git a/packages/app-server-client/tests/client.test.mjs b/packages/app-server-client/tests/client.test.mjs index c1ce752df..a74238759 100644 --- a/packages/app-server-client/tests/client.test.mjs +++ b/packages/app-server-client/tests/client.test.mjs @@ -4950,6 +4950,23 @@ test("uses agent-style stdio sidecar launch args", () => { "--data-dir", "/tmp/content-studio-app-server-data", ]); + assert.throws( + () => stdioSidecar("/tmp/app-server", undefined, "undefined"), + /App Server data directory is invalid/, + ); + assert.throws( + () => stdioSidecar("/tmp/app-server", undefined, "data"), + /App Server data directory must be an absolute path/, + ); + assert.throws( + () => + sidecarArgs({ + binaryPath: "app-server", + listenUrl: DEFAULT_LISTEN_URL, + dataDir: "undefined", + }), + /App Server data directory is invalid/, + ); assert.deepEqual( sidecarArgs({ binaryPath: "app-server", listenUrl: "stdio://" }), ["--stdio", "--backend", "unavailable"], diff --git a/packages/app-server-client/tests/remote.test.mjs b/packages/app-server-client/tests/remote.test.mjs new file mode 100644 index 000000000..278c8fc1f --- /dev/null +++ b/packages/app-server-client/tests/remote.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; + +import { WebSocketServer } from "ws"; +import { test } from "vitest"; + +import { + AppServerClient, + MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES, + connectRemoteAppServer, + RemoteTransport, +} from "../dist/index.js"; + +function initializeResponse( + id, + name = "app-server", + protocolVersion = "appserver.v0", +) { + return { + id, + result: { + serverInfo: { name, version: "test", protocolVersion }, + platform: { family: "unix", os: "test" }, + capabilities: { + agentSession: true, + capabilityDiscovery: true, + artifact: true, + evidence: true, + workspace: true, + }, + }, + }; +} + +async function listen(server) { + await once(server, "listening"); + const address = server.address(); + assert.equal(typeof address, "object"); + return `ws://127.0.0.1:${address.port}/rpc`; +} + +async function closeServer(server) { + for (const client of server.clients) { + client.close(); + } + if (server.address()) { + await new Promise((resolve) => server.close(resolve)); + } +} + +test("remote App Server uses authenticated headers and completes the shared handshake", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const url = `${await listen(server)}?tenant=public`; + const received = []; + let initialized; + let request; + + server.on("connection", (socket, httpRequest) => { + assert.equal(httpRequest.headers.authorization, "Bearer local-test-token"); + assert.equal(httpRequest.headers["x-lime-tenant-id"], "tenant-42"); + assert.equal(httpRequest.url, "/rpc?tenant=public"); + assert.equal(httpRequest.url.includes("local-test-token"), false); + socket.on("message", (data) => { + const message = JSON.parse(data.toString()); + received.push(message); + if (message.method === "initialize") { + socket.send(JSON.stringify(initializeResponse(message.id))); + } else if (message.method === "initialized") { + initialized = message; + } else if (message.method === "test/remote") { + request = message; + socket.send(JSON.stringify({ id: message.id, result: { ok: true } })); + } + }); + }); + + const connected = await connectRemoteAppServer( + { + websocketUrl: url, + authToken: "local-test-token", + tenantId: "tenant-42", + }, + { clientInfo: { name: "remote-test-client", version: "1" } }, + ); + const result = await connected.connection.request( + connected.client.request("test/remote", { value: 1 }), + ); + + assert.deepEqual(result.result, { ok: true }); + assert.equal(received[0].method, "initialize"); + assert.equal(initialized.method, "initialized"); + assert.equal(request.method, "test/remote"); + assert.equal(connected.initializeResponse.serverInfo.name, "app-server"); + + await connected.transport.close(); + await closeServer(server); +}); + +test("remote App Server rejects unsafe endpoint and credential configuration before dialing", async () => { + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "ws://cloud.example/rpc", + authToken: "secret", + }), + /wss:\/\//, + ); + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "wss://user:password@cloud.example/rpc", + }), + /userinfo or a fragment/, + ); + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "wss://cloud.example/rpc?access_token=secret", + }), + /credential query parameters/, + ); + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "wss://cloud.example/rpc", + authToken: " ", + }), + /auth token must not be empty/, + ); + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "wss://cloud.example/rpc", + authToken: "secret\n", + }), + /invalid header characters/, + ); + await assert.rejects( + () => + RemoteTransport.connect({ + websocketUrl: "wss://cloud.example/rpc", + tenantId: "\n", + }), + /invalid header characters/, + ); +}); + +test("remote App Server fails closed when identity or protocol does not match", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const url = await listen(server); + const closed = new Promise((resolve) => { + server.on("connection", (socket) => { + socket.once("close", resolve); + socket.once("message", (data) => { + const initialize = JSON.parse(data.toString()); + socket.send( + JSON.stringify(initializeResponse(initialize.id, "not-app-server")), + ); + }); + }); + }); + + await assert.rejects( + () => + connectRemoteAppServer( + { websocketUrl: url }, + { clientInfo: { name: "remote-test-client" } }, + ), + /identity mismatch/, + ); + await closed; + await closeServer(server); +}); + +test("remote App Server rejects an unsupported protocol before initialized", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const url = await listen(server); + const closed = new Promise((resolve) => { + server.on("connection", (socket) => { + socket.once("close", resolve); + socket.once("message", (data) => { + const initialize = JSON.parse(data.toString()); + socket.send( + JSON.stringify( + initializeResponse(initialize.id, "app-server", "appserver.v9"), + ), + ); + }); + }); + }); + + await assert.rejects( + () => + connectRemoteAppServer( + { websocketUrl: url }, + { clientInfo: { name: "remote-test-client" } }, + ), + /unsupported app-server protocol/, + ); + await closed; + await closeServer(server); +}); + +test("remote App Server transport rejects binary frames and enforces the configured message cap", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const url = await listen(server); + const connectedPromise = new Promise((resolve) => { + server.on("connection", (socket) => { + socket.once("message", (data) => { + const initialize = JSON.parse(data.toString()); + socket.send(JSON.stringify(initializeResponse(initialize.id))); + socket.once("message", () => { + resolve(socket); + socket.send(Buffer.from("binary")); + }); + }); + }); + }); + const connected = await connectRemoteAppServer( + { websocketUrl: url, maxMessageBytes: 1_024 }, + { clientInfo: { name: "remote-test-client" } }, + ); + assert.throws( + () => + connected.transport.send({ + id: 2, + method: "test/oversized", + params: { value: "x".repeat(2_048) }, + }), + /exceeds 1024 bytes/, + ); + await connectedPromise; + await assert.rejects( + () => connected.transport.nextMessage(1_000), + /non-text WebSocket frame/, + ); + assert.equal(MAX_REMOTE_WEBSOCKET_MESSAGE_BYTES, 128 * 1024 * 1024); + + await connected.transport.close(); + await closeServer(server); +}); + +test("remote handshake uses a caller-supplied AppServerClient request id", async () => { + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + const url = await listen(server); + let initializeId; + server.on("connection", (socket) => { + socket.once("message", (data) => { + const initialize = JSON.parse(data.toString()); + initializeId = initialize.id; + socket.send(JSON.stringify(initializeResponse(initialize.id))); + }); + }); + const client = new AppServerClient({ initialRequestId: 41 }); + const connected = await connectRemoteAppServer( + { websocketUrl: url }, + { clientInfo: { name: "remote-test-client" } }, + { client }, + ); + assert.equal(initializeId, 41); + await connected.transport.close(); + await closeServer(server); +}); diff --git a/packages/app-server-client/tsconfig.json b/packages/app-server-client/tsconfig.json index dbcd6807d..16625c875 100644 --- a/packages/app-server-client/tsconfig.json +++ b/packages/app-server-client/tsconfig.json @@ -16,5 +16,5 @@ "noUnusedParameters": false, "noFallthroughCasesInSwitch": true }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "src/**/*.d.ts"] } diff --git a/packages/cli/package.json b/packages/cli/package.json index 5eaf66f1c..b9dbabba6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime", - "version": "1.141.0", + "version": "1.142.0", "packageManager": "pnpm@9.15.9", "description": "Lime CLI is an agent that runs in your terminal.", "bin": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 944ac5f5f..6deab3c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,7 +416,11 @@ importers: specifier: 0.1.0 version: link:../agent-capability-catalog - packages/app-server-client: {} + packages/app-server-client: + dependencies: + ws: + specifier: ^8.18.3 + version: 8.18.3 packages/cli: {} diff --git a/scripts/app-server/cli-gate-b.mjs b/scripts/app-server/cli-gate-b.mjs index 492e3cd0f..ebf1a33d1 100644 --- a/scripts/app-server/cli-gate-b.mjs +++ b/scripts/app-server/cli-gate-b.mjs @@ -14,6 +14,7 @@ import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; import { localAppServerBinaryPath } from "../lib/electron-dev-sidecar.mjs"; +import { buildTerminalGateBinaries } from "./terminal-gate-binaries.mjs"; import { writeTerminalExternalBackend } from "./terminal-gate-fixture.mjs"; const __filename = fileURLToPath(import.meta.url); @@ -31,6 +32,7 @@ const prompt = "cli gate b prompt"; const completedText = "cli gate b completed"; async function main() { + await buildTerminalGateBinaries({ env: process.env, repoRoot: rootDir }); const cliBinaryPath = path.resolve( process.env.LIME_CLI_BIN || defaultCliBinaryPath, ); @@ -123,7 +125,7 @@ async function main() { ); assertEqual( turnStart.eventTypes.join(","), - "message.delta,item.started,item.completed,turn.completed", + "turn.started,message.delta,item.started,item.completed,turn.completed", "runtime event sequence", ); diff --git a/scripts/app-server/cli-gate-b.test.mjs b/scripts/app-server/cli-gate-b.test.mjs index ce32e182b..e3309503f 100644 --- a/scripts/app-server/cli-gate-b.test.mjs +++ b/scripts/app-server/cli-gate-b.test.mjs @@ -13,10 +13,12 @@ const fixtureSource = readFileSync( describe("CLI Gate B", () => { it("runs the real CLI and App Server through the current stdio boundary", () => { + expect(source).toContain("buildTerminalGateBinaries"); expect(source).toContain("spawn(cliBinaryPath"); expect(source).toContain('"--app-server"'); expect(source).toContain('"--app-server-arg=--backend"'); expect(source).toContain('"--app-server-arg=external"'); + expect(fixtureSource).toContain('"turn.started"'); expect(fixtureSource).toContain('"turn.completed"'); expect(source).toContain('envelope.result?.status, "ready"'); expect(source).toContain('argument === "--json" ? "--jsonl" : argument'); diff --git a/scripts/app-server/terminal-gate-binaries.mjs b/scripts/app-server/terminal-gate-binaries.mjs new file mode 100644 index 000000000..2702f5340 --- /dev/null +++ b/scripts/app-server/terminal-gate-binaries.mjs @@ -0,0 +1,67 @@ +import { spawn } from "node:child_process"; +import path from "node:path"; +import process from "node:process"; + +import { resolveRustyV8CargoEnv } from "../lib/rusty-v8-artifacts.mjs"; + +export function terminalGateBuildPackages(env = process.env) { + const packages = []; + if (!env.LIME_CLI_BIN?.trim()) { + packages.push("cli"); + } + if (!env.APP_SERVER_BIN?.trim()) { + packages.push("app-server"); + } + return packages; +} + +export function terminalGateCargoBuildArgs({ + env = process.env, + repoRoot = process.cwd(), +} = {}) { + const packages = terminalGateBuildPackages(env); + if (packages.length === 0) { + return []; + } + return [ + "build", + "--manifest-path", + path.resolve(repoRoot, "lime-rs", "Cargo.toml"), + ...packages.flatMap((packageName) => ["-p", packageName]), + ]; +} + +export async function buildTerminalGateBinaries({ + env = process.env, + repoRoot = process.cwd(), + runner = spawn, + resolveV8Env = resolveRustyV8CargoEnv, +} = {}) { + const args = terminalGateCargoBuildArgs({ env, repoRoot }); + if (args.length === 0) { + return; + } + const cargoCommand = process.platform === "win32" ? "cargo.exe" : "cargo"; + await new Promise((resolve, reject) => { + const child = runner(cargoCommand, args, { + cwd: repoRoot, + env: { + ...env, + ...resolveV8Env({ env, repoRoot }), + }, + shell: false, + stdio: "inherit", + }); + child.once("error", reject); + child.once("exit", (code, signal) => { + if (code === 0) { + resolve(); + return; + } + const detail = signal ? `signal=${signal}` : `code=${code ?? "unknown"}`; + reject( + new Error(`cargo build terminal Gate B binaries failed (${detail})`), + ); + }); + }); +} diff --git a/scripts/app-server/terminal-gate-binaries.test.mjs b/scripts/app-server/terminal-gate-binaries.test.mjs new file mode 100644 index 000000000..c0616da34 --- /dev/null +++ b/scripts/app-server/terminal-gate-binaries.test.mjs @@ -0,0 +1,42 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { + terminalGateBuildPackages, + terminalGateCargoBuildArgs, +} from "./terminal-gate-binaries.mjs"; + +describe("terminal Gate B binaries", () => { + it("builds both current binaries for default paths", () => { + expect(terminalGateBuildPackages({})).toEqual(["cli", "app-server"]); + expect( + terminalGateCargoBuildArgs({ env: {}, repoRoot: "/workspace/lime" }), + ).toEqual([ + "build", + "--manifest-path", + path.resolve("/workspace/lime/lime-rs/Cargo.toml"), + "-p", + "cli", + "-p", + "app-server", + ]); + }); + + it("only builds owners whose binary path was not supplied", () => { + expect(terminalGateBuildPackages({ LIME_CLI_BIN: "/tmp/lime" })).toEqual([ + "app-server", + ]); + expect( + terminalGateBuildPackages({ APP_SERVER_BIN: "/tmp/app-server" }), + ).toEqual(["cli"]); + }); + + it("does not rebuild explicitly supplied artifacts", () => { + const env = { + LIME_CLI_BIN: "/tmp/lime", + APP_SERVER_BIN: "/tmp/app-server", + }; + expect(terminalGateBuildPackages(env)).toEqual([]); + expect(terminalGateCargoBuildArgs({ env })).toEqual([]); + }); +}); diff --git a/scripts/app-server/terminal-gate-fixture.mjs b/scripts/app-server/terminal-gate-fixture.mjs index ca2c15f55..b501c395f 100644 --- a/scripts/app-server/terminal-gate-fixture.mjs +++ b/scripts/app-server/terminal-gate-fixture.mjs @@ -114,7 +114,8 @@ if (input.kind === "turnStart") { ]; } else if ( ${JSON.stringify(scenario)} === "interrupt" || - ${JSON.stringify(scenario)} === "queue-edit" + ${JSON.stringify(scenario)} === "queue-edit" || + ${JSON.stringify(scenario)} === "agents-overview" ) { events = [ { @@ -124,7 +125,9 @@ if (input.kind === "turnStart") { text: ${JSON.stringify(scenario)} === "queue-edit" ? "QUEUE_EDIT_READY" - : "INTERRUPT_READY", + : ${JSON.stringify(scenario)} === "agents-overview" + ? "AGENTS_OVERVIEW_READY" + : "INTERRUPT_READY", }, }, ]; @@ -156,6 +159,7 @@ if (input.kind === "turnStart") { { type: "turn.completed", payload: { status: "completed" } }, ]; } + events.unshift({ type: "turn.started", payload: {} }); } else if (input.kind === "actionRespond") { const decision = input.request.decision ?? null; const canceled = decision === "cancel"; @@ -213,7 +217,8 @@ console.log(JSON.stringify({ events })); if ( input.kind === "turnStart" && (${JSON.stringify(scenario)} === "interrupt" || - ${JSON.stringify(scenario)} === "queue-edit") + ${JSON.stringify(scenario)} === "queue-edit" || + ${JSON.stringify(scenario)} === "agents-overview") ) { // An unresolved Promise alone does not keep Node's event loop alive. Keep // one active handle so the App Server can deliver a real turn/interrupt. diff --git a/scripts/app-server/tui-gate-b.mjs b/scripts/app-server/tui-gate-b.mjs index 7db9141fe..4ba0fa4a6 100644 --- a/scripts/app-server/tui-gate-b.mjs +++ b/scripts/app-server/tui-gate-b.mjs @@ -3,6 +3,7 @@ import { execFile } from "node:child_process"; import { access, + mkdir, mkdtemp, readFile, readdir, @@ -15,6 +16,7 @@ import process from "node:process"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; import { localAppServerBinaryPath } from "../lib/electron-dev-sidecar.mjs"; +import { buildTerminalGateBinaries } from "./terminal-gate-binaries.mjs"; import { writeTerminalExternalBackend } from "./terminal-gate-fixture.mjs"; const execFileAsync = promisify(execFile); @@ -34,13 +36,14 @@ const queuePrompt = "queued follow-up for editing"; const completedText = "TUI_GATE_B_COMPLETED"; const scenarios = ( process.env.LIME_TUI_GATE_B_SCENARIOS || - "complete,approval,user-input,interrupt,failure,queue-edit" + "complete,approval,user-input,interrupt,failure,queue-edit,agents-overview" ) .split(",") .map((scenario) => scenario.trim()) .filter(Boolean); async function main() { + await buildTerminalGateBinaries({ env: process.env, repoRoot: rootDir }); const cliBinaryPath = path.resolve( process.env.LIME_CLI_BIN || defaultCliBinaryPath, ); @@ -69,7 +72,11 @@ async function main() { "", ].join("\n"), ); + const scenarioDirs = new Map(); for (const scenario of scenarios) { + const scenarioDir = path.join(tempDir, scenario); + await mkdir(scenarioDir, { recursive: true }); + scenarioDirs.set(scenario, scenarioDir); await writeTerminalExternalBackend(backendPath, { completedText, command: "printf tui-gate-b", @@ -100,7 +107,7 @@ async function main() { LIME_TEST_APP_SERVER_BIN: appServerBinaryPath, LIME_TEST_TERMINAL_BACKEND: backendPath, LIME_TEST_TERMINAL_LEDGER: ledgerPath, - LIME_TEST_TERMINAL_CWD: tempDir, + LIME_TEST_TERMINAL_CWD: scenarioDir, LIME_TEST_NODE_BIN: process.execPath, LIME_TEST_TERMINAL_PROMPT: prompt, LIME_TEST_TERMINAL_QUEUE_PROMPT: queuePrompt, @@ -119,7 +126,9 @@ async function main() { const turnStarts = scenarios.map((scenario) => { const entry = ledger.find( (candidate) => - candidate?.kind === "turnStart" && candidate.scenario === scenario, + candidate?.kind === "turnStart" && + candidate.scenario === scenario && + candidate.inputText === prompt, ); if (!entry) throw new Error( @@ -131,12 +140,14 @@ async function main() { return entry; }); const expectedSequences = { - complete: "message.delta,item.started,item.completed,turn.completed", - approval: "item.started,action.required", - "user-input": "item.started,action.required", - interrupt: "message.delta", - failure: "runtime.error,turn.failed", - "queue-edit": "message.delta", + complete: + "turn.started,message.delta,item.started,item.completed,turn.completed", + approval: "turn.started,item.started,action.required", + "user-input": "turn.started,item.started,action.required", + interrupt: "turn.started,message.delta", + failure: "turn.started,runtime.error,turn.failed", + "queue-edit": "turn.started,message.delta", + "agents-overview": "turn.started,message.delta", }; for (const [index, scenario] of scenarios.entries()) { assertEqual( @@ -189,7 +200,9 @@ async function main() { "queue-edit cleanup interrupt did not reach App Server backend", ); } - const runtimeEvents = await readRuntimeEvents(tempDir); + const runtimeEvents = await readRuntimeEvents( + scenarioDirs.get("queue-edit"), + ); const queueAdded = runtimeEvents.find( (event) => event?.type === "queue.added" && @@ -230,7 +243,24 @@ async function main() { ); } } - const turnStart = turnStarts[0]; + if (scenarios.includes("agents-overview")) { + const overviewTurnStart = turnStarts.find( + (entry) => entry.scenario === "agents-overview", + ); + const overviewCancel = ledger.find( + (entry) => + entry?.kind === "turnCancel" && + entry.scenario === "agents-overview" && + entry.threadId === overviewTurnStart.threadId && + entry.turnId === overviewTurnStart.turnId, + ); + if (!overviewCancel) { + throw new Error( + "agents overview stop did not reach the same App Server thread and turn", + ); + } + } + const turnStart = turnStarts.find(Boolean); console.log( [ @@ -241,6 +271,7 @@ async function main() { `turn=${turnStart.turnId}`, `events=${turnStart.eventTypes.join(",")}`, scenarios.includes("queue-edit") ? "queue-edit=ok" : null, + scenarios.includes("agents-overview") ? "agents-overview=ok" : null, "terminal=restored", ] .filter(Boolean) diff --git a/scripts/app-server/tui-gate-b.test.mjs b/scripts/app-server/tui-gate-b.test.mjs index 5eddfd4c8..77635047c 100644 --- a/scripts/app-server/tui-gate-b.test.mjs +++ b/scripts/app-server/tui-gate-b.test.mjs @@ -19,6 +19,8 @@ const ptyTestSource = readFileSync( describe("TUI Gate B", () => { it("drives the real TUI through a portable PTY and current App Server", () => { expect(gateSource).toContain('LIME_TEST_TUI_GATE_B: "1"'); + expect(gateSource).toContain("buildTerminalGateBinaries"); + expect(gateSource).toContain("LIME_TEST_TERMINAL_CWD: scenarioDir"); expect(gateSource).toContain('"--exact"'); expect(gateSource).toContain("writeTerminalExternalBackend"); expect(ptyTestSource).toContain('OsString::from("tui")'); @@ -37,13 +39,24 @@ describe("TUI Gate B", () => { expect(ptyTestSource).toContain('"esc to interrupt"'); expect(ptyTestSource).toContain('write_all(b"\\x1b[1;3A")'); expect(ptyTestSource).toContain('"editing queued"'); + expect(ptyTestSource).toContain('write_all(b"/agents\\r")'); + expect(ptyTestSource).toContain("writer.write_all(&[14])"); + expect(ptyTestSource).toContain("writer.write_all(&[18])"); + expect(ptyTestSource).toContain("writer.write_all(&[24])"); + expect(ptyTestSource).toContain('"Agent command center"'); + expect(ptyTestSource).toContain("vt100::Parser::new(24, 100, 0)"); + expect(ptyTestSource).toContain('"background task started"'); + expect(ptyTestSource).toContain('"1 working"'); + expect(ptyTestSource).toContain('"Rename:"'); + expect(ptyTestSource).toContain('"Gate B background"'); + expect(ptyTestSource).toContain('"AGENTS_OVERVIEW_READY"'); expect(gateSource).toContain('event?.type === "queue.added"'); expect(gateSource).toContain('event?.type === "queue.removed"'); expect(gateSource).toContain( 'event.payload?.source === "thread/queue/delete"', ); expect(gateSource).toContain( - '"complete,approval,user-input,interrupt,failure,queue-edit"', + '"complete,approval,user-input,interrupt,failure,queue-edit,agents-overview"', ); }); diff --git a/scripts/app-server/tui-structure-inventory.mjs b/scripts/app-server/tui-structure-inventory.mjs index 6c1608fce..ac783cd1a 100644 --- a/scripts/app-server/tui-structure-inventory.mjs +++ b/scripts/app-server/tui-structure-inventory.mjs @@ -46,7 +46,11 @@ async function main() { "Product-only account, onboarding, pets, updater and hosted Cloud surfaces stay excluded or deferred.", ], }; - await writeFile(outputPath, `${JSON.stringify(inventory, null, 2)}\n`, "utf8"); + await writeFile( + outputPath, + `${JSON.stringify(inventory, null, 2)}\n`, + "utf8", + ); console.log( `[inventory:tui-structure] wrote ${codex.files.length + lime.files.length} files to ${path.relative(rootDir, outputPath)}`, ); @@ -76,8 +80,8 @@ async function inspectTree(directory) { function extractSymbols(source, relativePath) { const patterns = [ - /^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z][A-Za-z0-9_]*)/gmu, - /^(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+([A-Za-z][A-Za-z0-9_]*)/gmu, + /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+([A-Za-z][A-Za-z0-9_]*)/gmu, + /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:struct|enum|trait|type)\s+([A-Za-z][A-Za-z0-9_]*)/gmu, ]; return patterns .flatMap((pattern) => diff --git a/scripts/app-server/tui-structure-inventory.test.mjs b/scripts/app-server/tui-structure-inventory.test.mjs index a5aca7e3c..fe595f068 100644 --- a/scripts/app-server/tui-structure-inventory.test.mjs +++ b/scripts/app-server/tui-structure-inventory.test.mjs @@ -16,7 +16,9 @@ describe("Codex TUI structure inventory", () => { it("records both TUI source trees", () => { expect(inventory.schemaVersion).toBe(1); expect(inventory.trees["codex-rs/tui/src"].fileCount).toBeGreaterThan(0); - expect(inventory.trees["lime-rs/crates/tui/src"].fileCount).toBeGreaterThan(0); + expect(inventory.trees["lime-rs/crates/tui/src"].fileCount).toBeGreaterThan( + 0, + ); }); it("locks Codex-shaped current TUI module and symbol names", () => { @@ -31,14 +33,36 @@ describe("Codex TUI structure inventory", () => { "resume_picker_transcript_preview.rs", "resume_picker_transcript_preview_tests.rs", "bottom_pane/chat_composer.rs", + "bottom_pane/chat_composer/attachment_state.rs", + "bottom_pane/chat_composer/draft_state.rs", + "bottom_pane/chat_composer/history_search.rs", + "bottom_pane/chat_composer/reconnect.rs", + "bottom_pane/chat_composer/reconnect_tests.rs", "bottom_pane/approval_overlay.rs", "bottom_pane/request_user_input/mod.rs", "bottom_pane/request_user_input/render.rs", "clipboard_copy.rs", "clipboard_paste.rs", "command_popup.rs", + "model_catalog.rs", + "collaboration_modes.rs", + "app/app_server_events.rs", + "app/app_server_requests.rs", + "app/event_dispatch.rs", + "app/input.rs", + "app/reconnect.rs", + "app/session_lifecycle.rs", + "app/pending_interactive_replay.rs", + "app/replay_filter.rs", + "app/thread_events.rs", + "app/thread_settings.rs", + "app/tests.rs", "pending_input_preview.rs", "terminal_hyperlinks.rs", + "reconnect.rs", + "bottom_pane/textarea.rs", + "bottom_pane/textarea/wrapping.rs", + "bottom_pane/textarea/wrapping_tests.rs", "terminal_palette.rs", "table_detect.rs", "wrapping.rs", @@ -63,8 +87,35 @@ describe("Codex TUI structure inventory", () => { "fmt_elapsed_compact", "ChatComposer", "InputResult", + "AttachmentState", + "DraftState", + "TextArea", + "TextAreaState", + "input", + "delete_backward", + "delete_forward", + "delete_forward_kill", + "delete_backward_word", + "delete_forward_word", + "kill_to_end_of_line", + "kill_to_beginning_of_line", + "yank", + "beginning_of_previous_word", + "end_of_next_word", + "handle_disconnected_key", + "cursor_pos_with_state", + "desired_height", + "reconnect_session", + "wrapped_lines", + "cursor_position", + "visible_prefix", "ApprovalOverlay", - "TerminalGuard", + "Tui", + "Terminal", + "with_restored", + "set_modes", + "restore_keep_raw", + "flush_terminal_input_buffer", "run_resume_picker_with_app_server", "run_fork_picker_with_app_server", "run_session_picker_with_app_server", @@ -83,6 +134,29 @@ describe("Codex TUI structure inventory", () => { "TranscriptReflowState", "TranscriptWidthChange", "SessionTranscriptState", + "ModelCatalog", + "default_mask", + "mask_for_kind", + "next_mask", + "default_mode_mask", + "plan_mask", + "to_mode", + "handle_app_server_event", + "handle_server_notification_event", + "handle_server_request_event", + "PendingInteractiveReplayState", + "note_server_request", + "note_server_notification", + "should_replay_snapshot_request", + "snapshot_has_pending_interactive_request", + "event_is_notice", + "omit_completed_agent_deltas", + "handle_event", + "EventContext", + "EventDispatch", + "handle_tui_event", + "handle_key_event", + "open_agent_picker", "render_expanded_session_details", "render_transcript_content_lines", "EventBroker", @@ -109,13 +183,229 @@ describe("Codex TUI structure inventory", () => { expect(files.has("composer.rs")).toBe(false); expect(files.has("bottom_pane/request_user_input.rs")).toBe(false); expect(files.has("terminal.rs")).toBe(false); + expect(symbols.has("TerminalGuard")).toBe(false); + expect(symbols.has("TuiTerminal")).toBe(false); + }); + + it("keeps app-server event routing in the Codex-named owner", () => { + const appServerEvents = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/app_server_events.rs", + ), + "utf8", + ); + const appServerRequests = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/app_server_requests.rs", + ), + "utf8", + ); + const runtime = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/runtime.rs"), + "utf8", + ); + const appServerClient = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/app-server-client/src/lib.rs", + ), + "utf8", + ); + const interactiveRuntime = runtime.slice( + 0, + runtime.indexOf("pub async fn run_exec"), + ); + + expect(appServerEvents).toContain("fn handle_app_server_event"); + expect(appServerEvents).toContain("fn handle_server_notification_event"); + expect(appServerEvents).not.toContain("fn handle_server_request_event"); + expect(appServerRequests).toContain("fn handle_server_request_event"); + expect(runtime).toContain("app.handle_app_server_event("); + expect(interactiveRuntime).not.toContain( + "AppServerEvent::ServerNotification", + ); + expect(interactiveRuntime).not.toContain("AppServerEvent::ServerRequest"); + expect(appServerClient).toContain("pub enum AppServerEvent"); + expect(appServerClient).not.toContain("pub enum SessionEvent"); + expect(appServerClient).not.toContain("RawNotification("); + expect(appServerClient).not.toContain("RawServerRequest("); + }); + + it("keeps replay filtering bounded by the Lime protocol", () => { + const replayFilter = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/replay_filter.rs", + ), + "utf8", + ); + expect(replayFilter).toContain("snapshot_has_pending_interactive_request"); + expect(replayFilter).toContain("event_is_notice"); + expect(replayFilter).toContain("omit_completed_agent_deltas"); + expect(replayFilter).not.toContain("omit_resolved_misalignment_errors"); + }); + + it("keeps App Server-backed action dispatch in the Codex-named owner", () => { + const eventDispatch = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/event_dispatch.rs", + ), + "utf8", + ); + const runtime = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/runtime.rs"), + "utf8", + ); + + expect(eventDispatch).toContain("pub(crate) async fn handle_event"); + expect(eventDispatch).toContain("pub(crate) struct EventContext"); + expect(runtime).toContain(".handle_event("); + expect(runtime).not.toContain("AppAction::SelectModel(selection) =>"); + expect(runtime).not.toContain("AppAction::RefreshAgentsOverview =>"); + }); + + it("keeps terminal input routing in the Codex-named owners", () => { + const app = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/app.rs"), + "utf8", + ); + const input = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/app/input.rs"), + "utf8", + ); + const runtime = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/runtime.rs"), + "utf8", + ); + + expect(app).toContain("mod input;"); + expect(app).toContain("mod tests;"); + expect(app).toContain("fn handle_tui_event"); + expect(input).toContain("fn handle_key_event"); + expect(runtime).toContain("app.handle_tui_event(event, connected)"); + expect(runtime).not.toContain("handle_terminal_event"); + expect(runtime).not.toContain("handle_disconnected_event"); + }); + + it("keeps reconnect lifecycle in the Codex-named app owner", () => { + const appReconnect = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/reconnect.rs", + ), + "utf8", + ); + const runtime = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/runtime.rs"), + "utf8", + ); + + expect(appReconnect).toContain("fn reconnect_session"); + expect(runtime).toContain("crate::app::reconnect::"); + expect(runtime).toContain("reconnect_session"); + expect(runtime).toContain("ReconnectedSession"); + }); + + it("keeps thread notification projection in the Codex-named owner", () => { + const threadEvents = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/thread_events.rs", + ), + "utf8", + ); + const appServerEvents = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/app_server_events.rs", + ), + "utf8", + ); + + expect(threadEvents).toContain("fn apply_notification"); + expect(threadEvents).toContain("fn observe_notification"); + expect(threadEvents).toContain("fn observe_item"); + expect(appServerEvents).toContain("self.apply_notification(notification)"); + expect(appServerEvents).not.toContain("fn observe_notification"); + }); + + it("keeps foreign interactive requests in ThreadEventStore only", () => { + const agentsOverview = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/agents_overview.rs", + ), + "utf8", + ); + const threadEvents = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/thread_events.rs", + ), + "utf8", + ); + const pendingReplay = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/pending_interactive_replay.rs", + ), + "utf8", + ); + + expect(agentsOverview).not.toContain("dispatched_requests"); + expect(agentsOverview).not.toContain("queue_agents_overview_request"); + expect(threadEvents).toContain("ThreadEventStore"); + expect(pendingReplay).toContain("PendingInteractiveReplayState"); + }); + + it("keeps thread settings state in the Codex-named owner", () => { + const threadSettings = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/thread_settings.rs", + ), + "utf8", + ); + const app = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/app.rs"), + "utf8", + ); + + expect(threadSettings).toContain("fn set_settings"); + expect(threadSettings).toContain("fn set_permission_profiles"); + expect(threadSettings).toContain("fn next_collaboration_mode"); + expect(app).not.toContain("fn sync_default_collaboration_mode"); + }); + + it("keeps agent picker lifecycle in the Codex-named owner", () => { + const app = readFileSync( + path.resolve(process.cwd(), "lime-rs/crates/tui/src/app.rs"), + "utf8", + ); + const sessionLifecycle = readFileSync( + path.resolve( + process.cwd(), + "lime-rs/crates/tui/src/app/session_lifecycle.rs", + ), + "utf8", + ); + + expect(app).toContain("mod session_lifecycle;"); + expect(sessionLifecycle).toContain("fn open_agent_picker"); }); it("keeps upstream product-only differences explicit", () => { expect(inventory.comparisons.filesMissingInLime).toContain( "onboarding/mod.rs", ); - expect(inventory.comparisons.symbolNamesMissingInLime.length).toBeGreaterThan(0); - expect(inventory.comparisons.filesOnlyInLime).not.toContain("session_picker.rs"); + expect( + inventory.comparisons.symbolNamesMissingInLime.length, + ).toBeGreaterThan(0); + expect(inventory.comparisons.filesOnlyInLime).not.toContain( + "session_picker.rs", + ); }); }); diff --git a/scripts/check-app-server-client-contract.mjs b/scripts/check-app-server-client-contract.mjs index b9fac5f40..3d867caf3 100644 --- a/scripts/check-app-server-client-contract.mjs +++ b/scripts/check-app-server-client-contract.mjs @@ -67,6 +67,7 @@ const appServerClientSplitSourceFiles = [ "packages/app-server-client/src/sidecar-manifest.ts", "packages/app-server-client/src/sidecar-process.ts", "packages/app-server-client/src/sidecar-lifecycle.ts", + "packages/app-server-client/src/remote.ts", "packages/app-server-client/src/agent-runtime.ts", ]; const rendererAppServerIndexFile = "src/lib/api/appServer.ts"; diff --git a/scripts/harness/deepswe-adapter-core.mjs b/scripts/harness/deepswe-adapter-core.mjs index 1e8bfad15..37200dc7d 100644 --- a/scripts/harness/deepswe-adapter-core.mjs +++ b/scripts/harness/deepswe-adapter-core.mjs @@ -2,6 +2,7 @@ import { execFileSync, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; import { parse as parseToml } from "smol-toml"; @@ -54,6 +55,10 @@ export const REQUIRED_VERIFIER_FILES = [ "test-stdout.txt", ]; const PATCH_CAPTURE_MAX_BYTES = 64 * 1024 * 1024; +const repoRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../..", +); const TERMINAL_TURN_STATUSES = new Set([ "completed", @@ -77,6 +82,41 @@ function commandOutput(command, args, options = {}) { }).trim(); } +export function resolveDockerHost(containerBin, env = process.env) { + const explicitHost = normalizeString(env.DOCKER_HOST); + if (explicitHost) return explicitHost; + + const contextResult = spawnSync(containerBin, ["context", "show"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env, + timeout: 15_000, + }); + if (contextResult.status !== 0) return ""; + const contextName = normalizeString(contextResult.stdout); + if (!contextName || contextName === "default") return ""; + + const endpointResult = spawnSync( + containerBin, + [ + "context", + "inspect", + "--format", + "{{.Endpoints.docker.Host}}", + contextName, + ], + { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + env, + timeout: 15_000, + }, + ); + return endpointResult.status === 0 + ? normalizeString(endpointResult.stdout) + : ""; +} + function runGit(cwd, args) { return commandOutput("git", args, { cwd }); } @@ -934,6 +974,11 @@ export function runPierVerifier({ const pierTempDir = path.join(runDir, "pier-tmp"); fs.mkdirSync(pierTempDir, { recursive: true }); const jobName = availablePierJobName(jobsDir, runId); + const dockerConfig = + process.env.LIME_DEEPSWE_DOCKER_CONFIG || + process.env.DOCKER_CONFIG || + path.join(repoRoot, ".lime/benchmark/tools/docker-cli-config"); + const dockerHost = resolveDockerHost(containerBin); const result = spawnSync( pierBin, [ @@ -964,6 +1009,8 @@ export function runPierVerifier({ env: { ...process.env, TMPDIR: pierTempDir, + DOCKER_CONFIG: dockerConfig, + ...(dockerHost ? { DOCKER_HOST: dockerHost } : {}), PIER_CONTAINER_BIN: containerBin, }, }, diff --git a/scripts/harness/deepswe-adapter.test.mjs b/scripts/harness/deepswe-adapter.test.mjs index 6a7394dfc..573a7fda4 100644 --- a/scripts/harness/deepswe-adapter.test.mjs +++ b/scripts/harness/deepswe-adapter.test.mjs @@ -17,6 +17,7 @@ import { preparePierReplayTask, prepareTaskWorkspace, readJson, + resolveDockerHost, runContextBase, runCurrentChainTask, runPierVerifier, @@ -212,9 +213,7 @@ describe("DeepSWE current-chain adapter", () => { sourceRoot: fixture.sourceRoot, taskId: "happy-dom-abort-pending-body-reads", }); - const fixtureTask = fixture.tasks.get( - "happy-dom-abort-pending-body-reads", - ); + const fixtureTask = fixture.tasks.get("happy-dom-abort-pending-body-reads"); expect(task).toMatchObject({ id: "happy-dom-abort-pending-body-reads", @@ -881,6 +880,8 @@ const verifierDir = path.join(jobsDir, jobName, "verifier"); fs.mkdirSync(verifierDir, { recursive: true }); fs.writeFileSync(path.join(path.dirname(jobsDir), "pier-child-env.json"), JSON.stringify({ tmpdir: process.env.TMPDIR, + dockerConfig: process.env.DOCKER_CONFIG, + dockerHost: process.env.DOCKER_HOST, containerBin: process.env.PIER_CONTAINER_BIN, args: process.argv.slice(2), })); @@ -890,7 +891,21 @@ fs.writeFileSync(path.join(verifierDir, "test-stdout.txt"), "passed"); `, { mode: 0o755 }, ); - fs.writeFileSync(containerBin, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + fs.writeFileSync( + containerBin, + `#!/bin/sh +if [ "$1" = "context" ] && [ "$2" = "show" ]; then + printf 'colima\n' + exit 0 +fi +if [ "$1" = "context" ] && [ "$2" = "inspect" ]; then + printf 'unix:///tmp/pier-test-docker.sock\n' + exit 0 +fi +exit 0 +`, + { mode: 0o755 }, + ); const result = runPierVerifier({ task: { taskDir }, @@ -904,6 +919,11 @@ fs.writeFileSync(path.join(verifierDir, "test-stdout.txt"), "passed"); const childEnv = readJson(path.join(runDir, "pier-child-env.json")); expect(childEnv).toMatchObject({ tmpdir: path.join(runDir, "pier-tmp"), + dockerConfig: path.join( + repoRoot, + ".lime/benchmark/tools/docker-cli-config", + ), + dockerHost: "unix:///tmp/pier-test-docker.sock", containerBin, }); expect(childEnv.args).toEqual( @@ -925,6 +945,33 @@ fs.writeFileSync(path.join(verifierDir, "test-stdout.txt"), "passed"); expect(result.checks.every((check) => check.passed === false)).toBe(true); }); + it("resolves the active Docker context endpoint for isolated CLI config", () => { + const root = temporaryRoot(); + const containerBin = path.join(root, "docker"); + fs.writeFileSync( + containerBin, + `#!/bin/sh +if [ "$1" = "context" ] && [ "$2" = "show" ]; then + printf 'colima\\n' + exit 0 +fi +if [ "$1" = "context" ] && [ "$2" = "inspect" ]; then + printf 'unix:///tmp/pier-test-docker.sock\\n' + exit 0 +fi +exit 0 +`, + { mode: 0o755 }, + ); + + expect( + resolveDockerHost(containerBin, { + ...process.env, + DOCKER_HOST: "", + }), + ).toBe("unix:///tmp/pier-test-docker.sock"); + }); + it("rejects a Pier binary that does not report the pinned 0.3.1 version", () => { const root = temporaryRoot(); const pierBin = path.join(root, "pier"); diff --git a/src/lib/api/oemCloudControlPlane.contract.test.ts b/src/lib/api/oemCloudControlPlane.contract.test.ts index 37a0020db..487776296 100644 --- a/src/lib/api/oemCloudControlPlane.contract.test.ts +++ b/src/lib/api/oemCloudControlPlane.contract.test.ts @@ -26,6 +26,7 @@ import { rotateClientAccessToken, updateClientSceneSkillPreferences, } from "./oemCloudControlPlane"; +import { parseAppServerConfig } from "./oemCloudControlPlaneCoreParsers"; describe("oemCloudControlPlane desktop auth", () => { beforeEach(() => { @@ -248,6 +249,15 @@ describe("oemCloudControlPlane desktop auth", () => { needsValidation: false, updatedAt: "2026-04-28T00:00:00.000Z", }, + appServer: { + enabled: true, + endpoint: "wss://llm.limeai.run/v1/app-server", + transport: "websocket", + protocol: "appserver.v0", + authorizationHeader: "Authorization", + authorizationScheme: "Bearer", + tenantHeader: "X-Lime-Tenant-ID", + }, features: { referralEnabled: true, }, @@ -298,6 +308,15 @@ describe("oemCloudControlPlane desktop auth", () => { }), ); expect(bootstrap.features.referralEnabled).toBe(true); + expect(bootstrap.appServer).toEqual({ + enabled: true, + endpoint: "wss://llm.limeai.run/v1/app-server", + transport: "websocket", + protocol: "appserver.v0", + authorizationHeader: "Authorization", + authorizationScheme: "Bearer", + tenantHeader: "X-Lime-Tenant-ID", + }); expect(bootstrap.referral?.share).toMatchObject({ brandName: "Lime", code: "LIME-2026", @@ -305,6 +324,23 @@ describe("oemCloudControlPlane desktop auth", () => { }); }); + it("应拒绝带凭证或 query 的 App Server endpoint", () => { + expect(() => + parseAppServerConfig({ + enabled: true, + endpoint: + "wss://user:secret@llm.example.com/v1/app-server?token=secret", + }), + ).toThrow("App Server endpoint"); + }); + + it("旧 bootstrap 缺少 appServer 时应保持显式关闭", () => { + expect(parseAppServerConfig(undefined)).toMatchObject({ + enabled: false, + transport: "websocket", + protocol: "appserver.v0", + }); + }); it("应读取并更新首页场景技能偏好", async () => { window.__LIME_SESSION_TOKEN__ = "session-token-001"; diff --git a/src/lib/api/oemCloudControlPlaneBootstrapParsers.ts b/src/lib/api/oemCloudControlPlaneBootstrapParsers.ts index 986e5289f..b443ab83b 100644 --- a/src/lib/api/oemCloudControlPlaneBootstrapParsers.ts +++ b/src/lib/api/oemCloudControlPlaneBootstrapParsers.ts @@ -1,6 +1,7 @@ import type { OemCloudBootstrapResponse } from "./oemCloudControlPlaneTypes"; import { parseAuthPolicy, + parseAppServerConfig, parseCurrentSession, parseFeatureFlags, parseGatewayConfig, @@ -52,6 +53,7 @@ export function parseBootstrap(value: unknown): OemCloudBootstrapResponse { ), }, authPolicy: parseAuthPolicy(value.authPolicy), + appServer: parseAppServerConfig(value.appServer), providerOffersSummary: Array.isArray(value.providerOffersSummary) ? value.providerOffersSummary.map(parseProviderOfferSummary) : [], diff --git a/src/lib/api/oemCloudControlPlaneCoreParsers.ts b/src/lib/api/oemCloudControlPlaneCoreParsers.ts index 169410f37..b068c0e21 100644 --- a/src/lib/api/oemCloudControlPlaneCoreParsers.ts +++ b/src/lib/api/oemCloudControlPlaneCoreParsers.ts @@ -1,6 +1,7 @@ import type { OemCloudAuthCatalogProvider, OemCloudAuthPolicy, + OemCloudAppServerConfig, OemCloudCurrentSession, OemCloudCustomScene, OemCloudDesktopAuthSessionStartResponse, @@ -114,7 +115,9 @@ export function parseAuthPolicy(value: unknown): OemCloudAuthPolicy { }; } -export function parsePublicAuthCatalog(value: unknown): OemCloudPublicAuthCatalog { +export function parsePublicAuthCatalog( + value: unknown, +): OemCloudPublicAuthCatalog { const record = isRecord(value) ? value : {}; return { providers: Array.isArray(record.items) @@ -183,7 +186,9 @@ export function parseProviderOfferSummary( }; } -export function parseProviderOfferDetail(value: unknown): OemCloudProviderOfferDetail { +export function parseProviderOfferDetail( + value: unknown, +): OemCloudProviderOfferDetail { const summary = parseProviderOfferSummary(value); const access = isRecord((value as Record).access) ? ((value as Record).access as Record) @@ -220,7 +225,9 @@ export function parseProviderOfferDetail(value: unknown): OemCloudProviderOfferD }; } -export function parseProviderModelItem(value: unknown): OemCloudProviderModelItem { +export function parseProviderModelItem( + value: unknown, +): OemCloudProviderModelItem { if (!isRecord(value)) { throw new OemCloudControlPlaneError("服务商模型格式非法"); } @@ -272,7 +279,9 @@ export function parseProviderModelItem(value: unknown): OemCloudProviderModelIte }; } -export function parseProviderPreference(value: unknown): OemCloudProviderPreference { +export function parseProviderPreference( + value: unknown, +): OemCloudProviderPreference { if (!isRecord(value)) { throw new OemCloudControlPlaneError("默认服务商配置格式非法"); } @@ -521,3 +530,44 @@ export function parseGatewayConfig(value: unknown): OemCloudGatewayConfig { tenantHeader: normalizeText(value.tenantHeader), }; } + +export function parseAppServerConfig(value: unknown): OemCloudAppServerConfig { + const record = isRecord(value) ? value : {}; + const enabled = normalizeBoolean(record.enabled); + const endpoint = normalizeText(record.endpoint); + + if (endpoint) { + let parsed: URL; + try { + parsed = new URL(endpoint); + } catch { + throw new OemCloudControlPlaneError("App Server endpoint 格式非法"); + } + if ( + (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") || + !parsed.host || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) { + throw new OemCloudControlPlaneError( + "App Server endpoint 必须是无凭证、无 query/fragment 的 ws/wss URL", + ); + } + } + + if (enabled && !endpoint) { + throw new OemCloudControlPlaneError("App Server 已启用但缺少 endpoint"); + } + + return { + enabled: enabled && Boolean(endpoint), + endpoint, + transport: "websocket", + protocol: "appserver.v0", + authorizationHeader: "Authorization", + authorizationScheme: "Bearer", + tenantHeader: "X-Lime-Tenant-ID", + }; +} diff --git a/src/lib/api/oemCloudControlPlaneTypes.ts b/src/lib/api/oemCloudControlPlaneTypes.ts index c4cd09745..cdee5cb57 100644 --- a/src/lib/api/oemCloudControlPlaneTypes.ts +++ b/src/lib/api/oemCloudControlPlaneTypes.ts @@ -213,6 +213,16 @@ export interface OemCloudGatewayConfig { tenantHeader?: string; } +export interface OemCloudAppServerConfig { + enabled: boolean; + endpoint?: string; + transport: "websocket"; + protocol: "appserver.v0"; + authorizationHeader: "Authorization"; + authorizationScheme: "Bearer"; + tenantHeader: "X-Lime-Tenant-ID"; +} + export interface OemCloudBootstrapResponse { session: OemCloudCurrentSession; app: { @@ -228,6 +238,7 @@ export interface OemCloudBootstrapResponse { authPolicy: OemCloudAuthPolicy; providerOffersSummary: OemCloudProviderOfferSummary[]; providerPreference: OemCloudProviderPreference; + appServer?: OemCloudAppServerConfig; skillCatalog?: unknown; serviceSkillCatalog?: unknown; sceneCatalog?: Array<{ id: string }>;