diff --git a/RELEASE_NOTES.en.md b/RELEASE_NOTES.en.md index fe7c2cc28..80abe7016 100644 --- a/RELEASE_NOTES.en.md +++ b/RELEASE_NOTES.en.md @@ -1,29 +1,47 @@ -## Lime v1.144.1 +## Lime v1.145.0 Simplified Chinese release notes are the primary version. ### New Features -- None. +- Continued Codex-shaped TUI owners for paginated history, resume/transcript browsing, export, MCP + inventory, user-input and approval flows, model/agent/resume pickers, and slash command popups. +- Added Vim editing sessions, undo/redo, reverse-history acceptance, image attachment queue editing, + and unified multi-agent, tool-lifecycle, and reasoning-status projection in the composer. ### Fixes -- Fixed the Windows CLI npm build by enabling the `windows-sys` `Win32_System_IO` feature required by `WriteFile`. +- Prevented late streaming deltas from reopening completed items or creating transcript rows after a + terminal turn. +- Fixed cross-page completion separators, review-prompt deduplication, cursor rereads, and transcript + scroll anchoring. +- Fixed narrow-terminal wrapping and overflow for CJK/emoji text, option windows, footers, cursors, + pickers, and composer layout. +- Fixed CLI npm platform-package resolution and naming, V8/Linux dependency handling, and npm token + propagation in the release job. ### Improvements and Refactoring -- None. +- Split TUI input, submission, interrupt, turn/tool lifecycle, and history-cell behavior into + Codex-shaped single owners while preserving the App Server JSON-RPC and canonical + Thread/Turn/Item projection chain. +- Consolidated localized MCP, approval, user-input, status/footer, and streaming copy with + fail-closed narrow-screen behavior across five locales. ### Testing and Quality -- Re-ran TUI Rust tests, formatting checks, and version consistency gates; GitHub Actions validates the cross-platform CLI artifacts. +- Added TestBackend/VT100 regressions for TUI pagination, overlays, composer, pickers, MCP, and + projection, and refreshed the TUI structure inventory and generated protocol types. +- Passed targeted and all-targets TUI tests, Clippy `-D warnings`, Cargo fmt, protocol contract tests, + real PTY/alternate-screen Gate B, and `git diff --check`. ### Documentation -- None. +- Updated the TUI/CLI Codex alignment execution plan and structure inventory. ### Other -- This patch release does not move the already published `v1.144.0` tag; CLI npm publishing continues through GitHub Actions npm Trusted Publishing. +- Kept the App Server user-input schema and generated TypeScript types synchronized; cross-platform + CLI artifacts continue to publish through GitHub Actions. -**Full changes**: `v1.144.0` -> `v1.144.1` +**Full changes**: `v1.144.1` -> `v1.145.0` diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 82ebff22b..8041ada43 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,27 +1,40 @@ -## Lime v1.144.1 +## Lime v1.145.0 ### 新功能 -- 无。 +- TUI 继续按 Codex current owner 对齐,补齐历史分页、resume/transcript 浏览、导出、MCP + inventory、请求用户输入、审批、模型/Agent/resume picker 与 slash popup 的交互路径。 +- Composer 支持 Vim 编辑会话、撤销/重做、反向历史搜索接受、图片附件队列编辑,以及 + 多 Agent、工具生命周期和 reasoning 状态的统一投影。 ### 修复 -- 修复 Windows CLI npm 构建缺少 `WriteFile` 所需 `windows-sys` `Win32_System_IO` feature 的问题。 +- 修复 streaming 回合结束后迟到 delta 重新打开已完成条目或凭空创建 transcript 行的问题。 +- 修复跨页历史的 completed-turn 分隔线、review prompt 去重、分页 cursor 重读和终端滚动锚点。 +- 修复窄终端下 CJK/emoji 换行、选项窗口、footer、光标定位、picker 锚定与 composer 布局越界。 +- 修复 CLI npm 平台包解析、平台包命名、V8/Linux 依赖和发布 job npm token 传递。 ### 优化与重构 -- 无。 +- 将 TUI 输入、提交、中断、turn/tool 生命周期和历史 cell 拆分为 Codex 形状的唯一 owner, + 继续复用 App Server JSON-RPC 与 canonical Thread/Turn/Item projection。 +- 收敛 MCP、approval、request_user_input、status/footer 和 streaming 的多语言文案与 + fail-closed 降级;补充五语言窄屏显示规则。 ### 测试与质量 -- 补跑 TUI Rust 测试、格式检查与版本一致性门禁;跨平台 CLI 产物由 GitHub Actions 验证。 +- 增加 TUI TestBackend/VT100、分页/overlay、composer、picker、MCP 与 projection 回归, + 并刷新 TUI 结构 inventory 与协议生成物。 +- 本轮已通过 TUI 定向与 all-targets 测试、Clippy `-D warnings`、Cargo fmt、协议契约测试、 + 真实 PTY/alternate-screen Gate B 和 `git diff --check`。 ### 文档 -- 无。 +- 更新 TUI/CLI Codex 对齐执行计划与结构 inventory。 ### 其他 -- 修复版不覆盖已发布的 `v1.144.0` tag;CLI npm 发布继续使用 GitHub Actions npm Trusted Publishing。 +- App Server request user input schema 与 TypeScript 生成类型保持同步;CLI 跨平台产物继续 + 由 GitHub Actions 发布。 -**完整变更**: `v1.144.0` -> `v1.144.1` +**完整变更**: `v1.144.1` -> `v1.145.0` diff --git a/internal/exec-plans/tui-cli-codex-sync-next-plan.md b/internal/exec-plans/tui-cli-codex-sync-next-plan.md index 6a72a86fb..689453e8d 100644 --- a/internal/exec-plans/tui-cli-codex-sync-next-plan.md +++ b/internal/exec-plans/tui-cli-codex-sync-next-plan.md @@ -258,6 +258,26 @@ Insert、附件、远程图片删除和空历史;完整 RuntimeKeymap、dot re `accepted_reverse_history_preview_starts_a_fresh_vim_edit_history` 回归;可配置 keymap、 异步 persistent history、跨后端历史查询与完整 ChatWidget/footer 行为仍保持 `partial/defer`。 + +本轮 MCP elicitation footer 切片(2026-09-15)按 Codex `FooterTip` 的显示优先级收口:宽屏 +继续复用五语言完整 controls 文案;窄屏按提交、字段/选择导航、取消的顺序拆分为多行,并对 +每个提示逐项降级(`Enter`/`Esc`、方向键、`Tab`),保证每行不超过可用宽度且取消动作落在 +最后一行。状态模型仍保持 Lime 的 `Option` 与硬编码按键边界,没有引入 Codex 私有 +`ScrollState`/keymap 或新的协议字段。新增五语言窄 footer 顺序与宽度回归;MCP 输入/选择 +状态同 Codex 的完整 keymap、错误提示分组和 scroll contract 继续标为 `partial/contract-defer`。 + +本轮底部交互窗口收口(2026-09-15):按 Codex `MAX_POPUP_ROWS`/selection window 语义, +approval、`request_user_input` 与 MCP elicitation 的选项窗口统一限制为 8 行,并保证当前 +选择始终可见;MCP elicitation 的标题、消息、字段描述按终端宽度换行,选项/输入保持单行 +省略,footer 在极窄宽度逐级压缩并保留 `Esc` 取消入口。`request_user_input` 与 MCP 文本 +输入的光标位置改为依据实际物理换行行数计算,避免长 CJK/多行草稿错位。新增 MCP 窄宽度 +行边界和选择窗口回归;相关实现仍只消费 App Server JSON-RPC 请求,不新增协议字段或第二 +套 runtime。 + +验证证据:TUI library `880/880`、integration `16/16`、TUI all-targets Clippy +`-D warnings`、workspace fmt check、`git diff --check`、`npm run test:contracts` 全部通过; +TUI Gate B(真实 PTY、alternate screen、stdio JSON-RPC、resize/reconnect、terminal restore) +沿用本批既有通过证据。未运行 `verify:gui-smoke`,因为本轮仅触及 Rust TUI。 无匹配时 Enter 保持搜索会话和原草稿,允许继续编辑查询;新增 `history_search_no_match_enter_keeps_search_open_for_query_edits` 回归。 本轮 history-search 可见契约补充(2026-09-13):Lime `ChatComposer` 复用 Codex 的单一 @@ -1036,3 +1056,577 @@ reconnect、resize 和 working-directory 的可用 current 子集已具备真实 `turn_42554e2130f141cdb58a0be4ce67384a`,事件为 `turn.started,message.delta,item.started,item.completed,turn.completed`,并再次证明 `focus-palette`、`resize-reflow`、`reconnect` 和 terminal restore 为 `ok`。 + +本轮 A2 首屏 paginated history contract 收口(2026-09-14): + +- 对照 Codex `app/history_pagination.rs` 与 `history_completion.rs`,首屏 + `thread/items/list` 不再直接作为无元数据 item 列表投影;`AppServerSession` 现在在同一 + canonical thread identity 下按 item page 的 `turnId` 查询 `thread/turns/list`,返回 + `InitialHistoryPage`,由 startup、resume、reconnect 三条入口与 older-page 共用 grouped + projection。completed Turn 的 separator 只落在该 Turn 最后 item,review prompt 只按 + canonical UserMessage ID 过滤。 +- Turn 查询是 enrichment contract:旧 App Server 缺少 `thread/turns/list`、目标 Turn 或 + 前序 Turn 时不猜测状态,保持 item-only/fail-closed;不新增本地 history store、协议字段 + 或 mock fallback。重连后的首屏也不再绕过相同过滤/完成边界。 +- 新增 TUI regression 覆盖首屏 completion/review filtering、缺失 Turn metadata 的 + item-only 行为,以及 Codex `merge=702` 对应的跨页重叠 answer 去重与 footer 边界;新增 + App Server JSON-RPC contract + `paginated_history_jsonrpc_preserves_canonical_thread_turn_item_identity`,断言 + `thread/resume`、`thread/turns/list`、`thread/items/list` 返回同一 canonical thread/turn/item + identity 与 answer 内容。 +- 已验证 TUI library `758/758`、integration `15/15`、TUI Clippy `-D warnings`、workspace + `cargo fmt --check`、`git diff --check`、`npm run test:contracts`。App Server Rust contract + 测试本机因 `rusty_v8` 150.4.0 + 没有当前 `aarch64-apple-darwin` 预编译包而无法编译,失败发生在 V8 下载阶段,未归因于 + contract 代码;后续具备 V8 构建/缓存后必须补跑该单测与 `npm run test:contracts`。 +- 该切片推进 A2 `history/transcript/pager` 主链,分类为 `current`;总体计划仍保持 + `in-progress`,A2 的跨页 nested-review 完整 Gate B 与其余 A3/A4/C/D 缺口不宣称完成。 + +本轮 A2 transcript overlay older-page 触顶加载(2026-09-14): + +- 对照 Codex `pager_overlay` 的 transcript 顶部历史加载语义,Lime `PagerOverlay` 在启用 + older page 的 transcript 且滚动到顶部时返回专用 `LoadOlderHistory` 动作;`App`/runtime + 复用现有 `thread/items/list` older-page loader 与 Turn enrichment,不在 overlay 内持有 + 第二套 history store。静态 status pager、resume picker 的 text-only transcript preview + 保持原有消费语义,不会误发分页请求。 +- `Ctrl+T` 打开 transcript 时从 `scrollback_has_older_history` 初始化可加载护栏;每次 older + page 完成后同步最新 `has_older_history`,加载中由 App Server cursor 状态抑制重复请求。 +- 新增 pager 与 App 输入路由回归,覆盖有/无 older page、Home/PageUp 触顶和静态 pager +- 验证:TUI library `762/762`、pager/App 定向测试、TUI all-targets Clippy `-D warnings`、 + workspace fmt check 与 `git diff --check` 通过;Gate B 与 App Server Rust contract 仍按 + 上一切片记录执行,V8 预构建包阻塞状态不变。 +- `npm run verify:local` 已启动但 Rust changed-scope 链接阶段因本机磁盘剩余约 1.2 GiB、 + 报 `No space left on device` 中止;该失败不是本轮代码诊断,待释放构建空间后补跑。 + +本轮 A2 Home 全历史与跨页 reconciliation 收口(2026-09-14): + +- `PagerAction::LoadOlderHistory` 进入统一 App history owner 后,Home 会沿 canonical + `thread/items/list` cursor 连续加载全部 older pages;普通 PageUp/ScrollUp 仍保持单页加载, + 不在常规滚动时预取整段历史。全量加载完成后清除旧的 transcript anchor,保证视口停在真正 + 的历史起点。 +- 初始 item-only 投影与后续 Turn metadata 现在复用同一页合并 helper;当相邻 completed + review turn + interrupted duplicate turn 到达时,按 canonical UserMessage ID 移除先前已显示 + 的 nested review prompt,再插入 older page,避免跨页重复和选中索引漂移。 +- 新增 `older_page_reconciles_nested_review_prompt_from_adjacent_turn_metadata` 与 pager + anchor 回归;结构 inventory 已重新生成。真实跨页 fixture/Gate B 仍待补齐,不能据此宣称 + A2 完成。该切片属于 `current`,没有新增协议字段、兼容包装、生产 mock 或本地存储。 +- 验证:本轮可运行的格式、结构/snapshot inventory `18/18` 与 `git diff --check` 通过;Rust + 定向编译因本机磁盘仅剩约 219 MiB 而报 `No space left on device`,待释放构建空间后补跑。 + +本轮 A2 MCP canonical inventory 窄切片(2026-09-14): + +- 对照 Codex `/mcp` inventory 语义,TUI slash catalog 新增 `/mcp`;`/mcp` 请求 + `mcpServerStatus/list` 的 `ToolsAndAuthOnly` 详情,`/mcp verbose` 请求 `Full` 详情,未知 + 参数在本地化 usage 中 fail-closed。App Server session 复用现有 JSON-RPC request boundary, + 沿 `nextCursor` 最多读取 16 页;重复 cursor 或超页数立即失败,不引入本地 MCP 状态存储、 + 第二套 transport 或 provider-specific 解析。 +- inventory 仅消费 canonical `McpServerStatus`,服务器按名称排序并渲染连接状态、工具计数; + `Full` 额外展示 Auth、工具、资源和资源模板名称/URI,工具、资源和模板空集合统一显示 + 本地化的 `(none)`,资源与模板保持 App Server 返回顺序。未知状态与 `None + NotLoggedIn` + 按 Codex 语义安全降级。结果通过现有静态 `PagerOverlay` 展示, + 不改变 transcript pager 的 older-history 动作路由。 +- 新增 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` 文案、slash catalog/action/detail + 回归及 inventory 排序、Full 详情、空状态单测。该切片分类为 `current`,但 MCP resource body、 + rmcp transport、Node/CUA REPL 与完整真实 MCP inventory PTY fixture 仍为 `contract/defer`, + 未宣称 A2 或 MCP Gate B 完整完成。 +- 本轮验证已完成:`cargo fmt --all --check`、TUI library `772/772`、TUI all-targets + Clippy `-D warnings`、`git diff --check`、`npm run test:contracts`、 + `npm run inventory:tui-structure` 均通过;真实 `npm run smoke:tui-gate-b` 通过,thread + `01a09d7a-ca8c-7081-af24-b12dcda1828d`、turn `turn_a2c1056951f3414c91a6be570c012f4d`, + 证明 PTY、alternate screen、stdio App Server JSON-RPC、canonical Thread/Turn/Item、 + queue-edit、agents-overview、focus-palette、resize-reflow、reconnect 与 terminal restore + 主链未回归。该 Gate B fixture 未发送 `/mcp` 请求,因此不能作为 MCP inventory 的真实交互 + 证据。App Server Rust contract 仍受本机 `rusty_v8` 150.4.0 Apple ARM 预构建包缺失阻塞。 + +本轮 A2 跨页 nested-review 真实 Gate B 收口(2026-09-14): + +- `thread/fork` 仅允许已知且带 review 文本的 `enteredReviewMode`/`exitedReviewMode` 扩展项, + 其他 Extension/Unknown 仍 fail-closed;provider history lowering 对这两个边界项显式忽略, + 不将 UI review 标记误送给模型。新增 runtime 单测覆盖合法、缺失 review 文本和未知扩展。 +- 新增 `scripts/app-server/tui-history-pagination-fixture.mjs` 与真实 PTY suite,使用 external + backend 仅作为显式测试夹具:51 个 completed turns 形成 >100 item 分页,真实 review turn、 + steer duplicate prompt、fork 中断尾回合,再以 `lime resume` 驱动 transcript Home/End。夹具 + 证明 `SEED_000`/`SEED_050` 可见、`NESTED_REVIEW_PROMPT` 按 canonical UserMessage ID 隐藏、 + completion separator 无相邻重复且 alternate screen 恢复。 +- 新增 npm 入口 `smoke:tui-history-pagination`;清理 TUI history enrichment 临时 debug 输出。 +- 验证:真实 `LIME_KEEP_TUI_HISTORY_PAGINATION_TMP=1 node scripts/app-server/tui-history-pagination-fixture.mjs` + 通过;`cargo fmt --manifest-path lime-rs/Cargo.toml --all --check`、`node --check`、 + `npm run test:contracts`、`npm run governance:scripts` 通过;TUI history_filter、history_pagination、 + pager_overlay 定向测试全部通过。App Server Rust tests 在当前机器仍受 rusty_v8 150.4.0 + Apple ARM 预编译包缺失(HTTP 404)阻塞,需具备本地 V8 archive 后补跑。 + +本轮 A3 status indicator widget 收口(2026-09-14): + +- 对照 Codex `status_indicator_widget.rs` 的唯一绘制 owner 语义,Lime 将耗时、可中断提示、 + inline context、hook 状态溢出和 details wrapping 收拢到 + `tui/src/status_indicator_widget.rs`;状态行宽度测量与渲染共用同一 `lines` 路径,hook + 文案无法放入首行时移到第二行,details 按显示宽度截断并保留省略号。 +- `ConversationProjection::hook_status_message()` 作为 hook display-ready 文案事实源, + `view::screen_chunks` 使用 `desired_height_with_status` 与渲染传入相同输入,避免窄终端或 + hook 活动时覆盖队列/编辑器。旧 `status_indicator.rs` 仅保留兼容委托和历史测试,不再持有 + 绘制算法;没有新增 runtime、协议字段、mock 或本地状态存储。 +- 迁移并补齐 Codex 同名 status 测试:无动画状态、重映射 interrupt hint、hook reflow、 + details overflow/capitalization,以及五语言文案和窄宽度边界。该切片分类为 `current`; + 旧模块为 `compat`,完整 Codex `StatusTimer`/spinner/shimmer、可配置 keymap 与 frame + requester 仍为 `partial/defer`,待独立 owner 和真实交互需求确认后再迁移。 +- 验证:TUI status 定向测试 `10/10`(含暂停感知 `StatusTimer`)、TUI library `782/782`、 + TUI integration `16/16`、TUI Clippy `-D warnings`、workspace fmt、结构/snapshot inventory + `18/18`、`npm run test:contracts`、`npm run governance:scripts` 与 `git diff --check` 均通过。 + 完整 + `smoke:tui-gate-b` 未在本切片重复执行;上一切片已有真实 PTY/alternate-screen/stdio + App Server JSON-RPC 证据,后续若接入动态 timer/animation 必须补跑 Gate B。 + +本轮 A3 footer 单行布局折叠收口(2026-09-14): + +- 在 bottom_pane/footer.rs 唯一 current owner 中补齐 Codex 形状的 SummaryLeft、SummaryHintKind、single_line_footer_layout、can_show_left_with_context、right_aligned_x 和 render_context_right。活动回合草稿优先显示 Tab queue hint,窄终端先隐藏 active agent context,再降级短 queue hint;非活动草稿显示本地化 draft 状态;无草稿保留 turn/context 左右布局与 Vim indicator。 +- 布局统一使用 line_width/display_width,覆盖中日韩和 emoji 宽度边界;新增 queue_message_hint、queue_short_hint、draft_ready_hint,覆盖 zh-CN、zh-TW、en-US、ja-JP、ko-KR。未复制无 Lime consumer 的 Codex shortcuts overlay、voice、IDE context、动态 status-line 和完整 RuntimeKeymap。 +- 分类:footer queue/context 单行折叠为 current;完整 Codex footer mode、voice、外部编辑器提示和配置化 keymap 为 partial/defer。未新增协议字段、runtime、兼容包装、生产 mock 或本地存储。 +- 验证:footer 定向测试 9/9、TUI library 786/786、TUI Clippy(tui lib no-deps,D warnings)、workspace fmt、结构 inventory、git diff check、npm run test:contracts 与 npm run smoke:tui-gate-b 均通过。Gate B 证明 PTY、alternate screen、stdio App Server JSON-RPC、canonical Thread/Turn/Item、queue-edit、agents-overview、focus-palette、resize-reflow、reconnect 和 terminal restore 正常。workspace 全量 Clippy 仍受并行既有 agent-protocol lint 阻塞;verify:gui-smoke 未重复执行,footer 不触达 Electron bridge。 + +本轮 A3 selection-row / Agents Overview 对齐(2026-09-14): + +- 新增 selection_row_layout.rs 作为选择行唯一 current display-layout owner,按 Codex 语义统一名称、前缀、描述列、disabled reason、UTF-8 grapheme/CJK/emoji 显示宽度和窄终端堆叠;model_picker.rs、app/agent_picker.rs 复用该 owner,保留各自筛选、导航、Enter/Esc、App Server 数据和路由边界。 +- Agents Overview 列表行迁移到同一 owner:状态 marker、current 标记、localized 状态与 cwd/project 说明共享显示宽度与窄终端折叠逻辑;不改变 AgentsOverviewView 选择、搜索、重命名、停止、派发和 App Server refresh 主链。新增可见行回归锁定 marker、current、状态和 cwd 事实。 +- selection_list.rs 当前仅剩自身测试和模块注册,未发现生产 consumer;在未取得高风险删除确认前不直接移除,分类为 dead-candidate/defer,后续需先确认是否迁移其测试语义并补回流守卫。完整 Codex ListSelectionView 的 toggle/tab/shortcut、side-content、配置化 keymap 等能力因无 Lime current consumer 继续为 contract/defer,不机械复制第二套状态机。 +- 分类:selection_row_layout、model/agent picker 与 Agents Overview 行渲染属于 current;没有新增 compat 或 deprecated surface;无 Lime consumer 的完整 ListSelectionView 能力为 contract/defer;selection_list.rs 为 dead-candidate/defer,待确认后删除或迁移测试。 +- 验证:Agents Overview 定向测试 17/17;TUI library 790/790;TUI integration 16/16;manager dependency regression 1/1;cargo clippy -p tui --all-targets --no-deps -- -D warnings、cargo fmt --all -- --check、git diff --check、npm run inventory:tui-structure、npm run test:contracts 与真实 npm run smoke:tui-gate-b 均通过。Gate B 新证据线程 01a09e2d-3459-7712-91ab-aff245585a72、回合 turn_a33e37bb3a204017a4a9b96a935b12d0,证明真实 lime、PTY/alternate screen、stdio App Server JSON-RPC、canonical Thread/Turn/Item、agents-overview、queue-edit、focus-palette、resize-reflow、reconnect 与 terminal restore 正常。 +- 本轮仍未执行 verify:gui-smoke(未触及 Electron/GUI bridge),App Server Rust contract 仍受本机 rusty_v8 150.4.0 Apple ARM 预构建包缺失阻塞;总体计划继续保持 in-progress。下一刀回到 A3 剩余 current composer/bottom-pane owner,或在确认真实 consumer 后再处理 selection_list.rs。 + +本轮 A3 completion-target owner 收口(2026-09-14): + +- 对照 Codex `bottom_pane/chat_composer/completion_target.rs` 与同名测试,Lime 将 `@` 文件和 `$` 技能的 cursor-neighborhood 解析统一到 `chat_composer/completion_target.rs`;横向空白保留同一行 affinity,换行是硬边界,光标位于新 token 起点时优先右侧目标,shell-like `$` 左目标在右侧可补全目标存在时让位。解析器使用 UTF-8 安全 byte range,不跨越普通文本或嵌套 `$` 前缀误切 token。 +- `skill_query_is_candidate` 改为消费 `DollarQueryKind`:空/小写或冒号限定 skill 可补全,常见环境变量、确定 positional parameter、非法语法拒绝,数字或 `-` 前缀的歧义参数仅在 catalog 存在精确 skill 时放行。删除 `chat_composer.rs` 中旧 `current_at_token_range`、`current_dollar_token_range`、`is_common_shell_variable` 生产实现,测试 helper 仅委托 current owner。 +- 新增 completion-target 回归覆盖相邻 `$`/`@` 目标、separator affinity、换行与尾随空白、UTF-8 非边界光标、`$HOME`、`$1`、`$1_suffix`、`$-x`、`$-`、`$_` 和嵌套 `$HOME/$USER`。Codex atomic text-element binding 在 Lime 没有 current canonical consumer,继续分类为 `contract/defer`,未伪造第二套 text-element API。 +- 分类:completion-target、ChatComposer 接线和 shell 语法 arbitration 属于 `current`;Codex 完整 atomic mention binding、plugin/connectors 与更丰富 completion UI 继续 `contract/defer`。未新增协议字段、兼容包装、生产 mock、runtime 或本地存储。 +- 验证:completion-target 定向测试 `10/10`,TUI library `800/800`,TUI all-targets Clippy `-D warnings`、`cargo fmt --all`、`npm run inventory:tui-structure`、`git diff --check` 均通过;`npm run test:contracts` 退出码 `0`(协议类型生成无漂移、App Server client contract 299 checks、command contracts、harness、modality、scripts、Electron release、desktop/CLI/docs boundary 均通过)。真实 `npm run smoke:tui-gate-b` 退出码 `0`,thread `01a09e65-4e34-7033-97f3-d8cfe34f53a6`、turn `turn_168e63af64e0442498a7a8bd45e3badf`;事件为 `turn.started,message.delta,item.started,item.completed,turn.completed`,`queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、`reconnect` 和 terminal restore 均为 `ok`。Gate B 编译期间的 `lower_turn_start_params`、`lower_runtime_options` dead-code warning 为既有 App Server 警告,非本切片引入。总体计划继续保持 `in-progress`,下一刀回到 A3 剩余 composer/bottom-pane current owner。 + +本轮 A3 popup-state owner 收口(2026-09-14): + +- 对照 Codex `bottom_pane/chat_composer/popup_state.rs` 与同名测试,Lime 将文件/技能 dismissal token 及文件搜索重复 query 状态从 `ChatComposer` 聚合字段迁入唯一 `PopupState` owner;command/file/skill popup 的可见性与 transient dismissal/query 生命周期由同一状态对象承接。文件搜索 generation/request 仍保留在 `ChatComposer`,因为它属于 App Server 请求边界,不在 popup owner 内伪造 transport 状态。 +- 保持现有行为:取消文件/技能 popup 仍按 token occurrence 抑制当前实例;完成或切换 token 清理对应 dismissal;相同文件 query 不重复发请求;history navigation、空 `@` 和 slash popup 互斥逻辑不变。未新增协议字段、runtime、history store、生产 mock 或兼容包装。 +- 分类:`PopupState` 与 ChatComposer 接线属于 `current`;Codex atomic text-element dismissal、MentionV2、voice strip 与完整 ChatWidget popup layout 在 Lime 没有 current canonical consumer,继续 `contract/defer`,不机械复制。 +- 验证:popup-state 定向测试 `3/3`、TUI library `800/800`、TUI all-targets Clippy `-D warnings`、`cargo fmt --all -- --check`、`git diff --check` 与 `npm run inventory:tui-structure`(`870` 个文件)均通过。真实 `npm run smoke:tui-gate-b` 退出码 `0`,thread `01a09e6d-b932-79d1-b774-2f0cccbc6478`、turn `turn_dfaf2afbaac44897a6eda38067c65725`;事件为 `turn.started,message.delta,item.started,item.completed,turn.completed`,`queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、`reconnect` 和 `terminal=restored` 均为 `ok`。Gate B 编译期间的 `lower_turn_start_params`、`lower_runtime_options` dead-code warning 为既有 App Server 警告,非本切片引入。总体计划继续保持 `in-progress`,下一刀回到 A3 其余 composer/bottom-pane current owner。 + +本轮 A3 agents-navigation owner 收口(2026-09-14): + +- 对照 Codex 当前 bottom_pane/chat_composer/agents_navigation.rs 语义,Lime 新增该模块作为空草稿 Agents Overview 导航的唯一 composer owner。ChatComposer 仅在本地 stdio 会话启用无修饰 Left,返回 OpenAgentsOverview;App 统一映射为 open_agents_overview() 与 AppAction::RefreshAgentsOverview,嵌套 request_user_input composer 对该结果 fail-closed 忽略。remote session 默认关闭,不引入第二套导航状态机。 +- 导航可用性严格要求空文本、无附件、无 popup、无 history search、无 Vim operator pending,且新增 vim_search_active() 护栏;Alt/其他修饰键不会窃取编辑器输入。新增 6 项 owner 回归与 2 项 App 路由回归,覆盖默认/remote 关闭、popup、附件、Vim operator、活动 Vim search、修饰键和空编辑器 Left。 +- 分类:agents_navigation.rs、ChatComposer 接线与 App Action 路由属于 current;remote session 保持 fail-closed;Codex 完整 focus/input-enabled/runtime keymap 等 Lime 尚无 canonical consumer 的能力继续 partial/contract-defer,未新增协议字段、runtime、history store、生产 mock 或 compat 包装。 +- 验证:agents-navigation 定向测试 6/6、空编辑器 Left 路由 2/2;TUI library 808/808、integration 16/16、manager regression 1/1;TUI all-targets Clippy --no-deps -- -D warnings、workspace fmt check、git diff --check 与 npm run inventory:tui-structure(871 个文件)通过。真实 npm run smoke:tui-gate-b 通过,thread 01a09e88-2b74-7560-a388-00d9a41c3ced、turn turn_41f872bac11d4a3cbf02bd12469a5b0a;事件为 turn.started,message.delta,item.started,item.completed,turn.completed,queue-edit、agents-overview、focus-palette、resize-reflow、reconnect 和 terminal=restored 均为 ok。Gate B 编译期间的 lower_turn_start_params、lower_runtime_options dead-code warning 为既有 App Server 警告,非本切片引入。未触及 Electron/GUI bridge,未运行 verify:gui-smoke;总体计划继续保持 in-progress。 + +本轮 A3 request_user_input 焦点与问题导航对齐(2026-09-14): + +- 对照 Codex 当前 bottom_pane/request_user_input/mod.rs 与 async_questions 交互语义,Lime RequestUserInputOverlay 为每个问题保存独立的备注草稿、选项选择和 Options/Notes 焦点;Ctrl-P/Ctrl-N、PageUp/PageDown 以及 Options 焦点下的 h/l、Left/Right 可循环切换问题,切换不会丢失其他问题的编辑状态。Options 按 Tab 进入 Notes 时先恢复当前草稿再显式持久化 Notes 焦点,避免旧缓存覆盖新状态。 +- 选项题支持 Other 两阶段 Enter:首次进入备注编辑,第二次提交 Other 及可选 user_note: ...;空备注仍只提交已选项。Notes 焦点下 Esc、空 Backspace 或 Tab 清空备注并返回 Options;Options 焦点下普通字符保持 Codex 的 Options 焦点,j/k 与 Up/Down 移动选项、h/l 与 Left/Right 导航问题、空格不提交,数字快捷键按选项提交。v2 answers 结构保持不变,没有伪造 Codex 私有字段。 +- 新增回归覆盖逐题 draft/selection 保留、Other 两阶段提交、Options/Notes Tab 往返、Esc/空 Backspace fail-closed、Options 输入不打开 Notes 以及 j/k 选项导航;该切片分类为 current。Codex 完整 async_questions 队列/确认未答题、自动解析、中断后持久化、可配置 keymap 与私有 composer draft 仍因 Lime canonical contract 不承载而分类为 partial/contract-defer,不得通过本地 history store 或新增协议字段补造。 +- 验证:cargo fmt --manifest-path lime-rs/Cargo.toml --all -- --check、request_user_input 定向测试 12/12、TUI library 812/812、integration 16/16、manager regression 1/1、TUI Clippy --all-targets --no-deps -- -D warnings、git diff --check 与 npm run inventory:tui-structure(871 个文件)均通过。真实 npm run smoke:tui-gate-b 通过,thread 01a09ec7-4003-7240-8068-aec662bd70c6、turn turn_bca42a7b300640b78bde736af2e579ff;事件为 turn.started,message.delta,item.started,item.completed,turn.completed,queue-edit、agents-overview、focus-palette、resize-reflow、reconnect 和 terminal restore 均为 ok。本轮未触及 Electron/GUI bridge,未运行 verify:gui-smoke;Gate B 证明 TUI/CLI current 主链未回归,不等同于完整 async_questions contract 完成。总体计划继续保持 in-progress。 + +本轮 A3 request_user_input 非阻塞协议与自动解析子集(2026-09-14): + +- `ToolRequestUserInputParams` 新增 current `isBlocking` 字段;手写反序列化对缺少字段的旧请求 fail-closed 为 `true`,`autoResolutionMs` 保留为 deprecated 协议兼容字段。App Server action payload 同时读取 camelCase/snake_case 并默认阻塞,`RequestUserInputRunRequest`、`RequestUserInputAction` 与 Agent bridge 全链路透传,bridge 发出 `isBlocking`。v2 DTO、App Server schema、TypeScript generated client 与相关 fixture 已同步,未建立第二套协议或生产 mock。 +- `CurrentTurnToolExecutor` 按 canonical collaboration mode 推导语义:`Plan` 阻塞、`Default` 非阻塞、缺少协作模式时阻塞,避免未知上下文自动放行。新增 Plan/Default/缺省判定回归。 +- TUI `RequestUserInputOverlay` 对 `isBlocking=true` 保持人工回答;`false` 使用 60 秒隐藏 grace,随后 60 秒可见倒计时,到期提交空 answers。任意键或粘贴会 snooze 自动解析;倒计时通过既有 `FrameRequester` 驱动,并覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR`。这只是 Codex async_questions 的 current 子集,队列确认未答题、可配置 keymap、恢复持久化和更完整私有 composer 状态仍为 `partial/contract-defer`。 +- 分类:`isBlocking` 协议字段、App Server/Agent bridge 透传和 TUI 自动解析状态机属于 `current`;`autoResolutionMs` 为协议兼容 `deprecated` 字段;没有新增 compat 包装、local history store、生产 mock 或 Electron bridge。 +- 验证:`npm run test:contracts` 退出码 `0`;Rust runner 定向测试通过(agent-runtime request_user_input 5/5、lime-agent 3/3、app-server approval parser 定向测试含显式 `isBlocking=false`、app-server-protocol 133/133、TUI 816/816),`cargo clippy --locked --manifest-path lime-rs/Cargo.toml -p tui --all-targets --no-deps -- -D warnings`、workspace fmt check、`npm run inventory:tui-structure`(871 个文件)与 `git diff --check` 均通过。真实 `npm run smoke:tui-gate-b` 退出码 `0`,thread `01a09f1e-18b0-7ae2-aa21-6d0ce9807ac6`、turn `turn_3609a06f81fd4c689e88bd729e375c83`;事件为 `turn.started,message.delta,item.started,item.completed,turn.completed`,queue-edit、agents-overview、focus-palette、resize-reflow、reconnect 和 terminal restore 均为 `ok`。Gate B 编译期间的 `lower_turn_start_params`、`lower_runtime_options` dead-code warning 为既有 App Server 警告,非本切片引入;未触及 Electron/GUI bridge,未运行 `verify:gui-smoke`。总体计划继续保持 `in-progress`,A1/A2 其余 history contract、A3/A4 剩余 owner、CLI partial 与 Cloud transport 仍未完成。 + +本轮 A3 pending-input preview current owner 收口(2026-09-14): + +- 对照 Codex `bottom_pane/pending_input_preview.rs` 的 bottom-pane owner 边界,Lime 将 + `pending_input_preview.rs` 的生产实现固定在 `tui/src/bottom_pane/pending_input_preview.rs`; + `view`、`app` 和输入路由继续直接消费该 current owner。根模块只保留无业务逻辑的 + `compat` 重导出,避免根聚合文件与 bottom-pane 形成第二个事实源;未引入 Lime 当前协议 + 不承载的 Codex pending/rejected steer 状态、voice 或 RuntimeKeymap。 +- `can_restore_submission`、队列多模态摘要、窄终端折叠和五语言文案仍由 current owner + 统一提供;根 compat 不新增行为。当前 Codex 完整 steer 分段与动态 binding 因没有 Lime + canonical consumer,继续 `partial/contract-defer`,不通过本地状态或 mock 补造。 +- 分类:`bottom_pane/pending_input_preview.rs` 为 `current`;根 + `pending_input_preview.rs` 为 `compat`,只委托;未删除文件、未新增协议字段、runtime、 + 持久化或生产 mock。 +- 验证:pending preview 定向测试 `4/4`、TUI all-targets Clippy `-D warnings`、workspace + fmt check、`git diff --check`、`npm run inventory:tui-structure`(`872` 个文件)和 + `npm run test:contracts` 均通过。真实 `npm run smoke:tui-gate-b` 通过,thread + `01a09f72-dd15-7df2-8ad7-847719488c8b`、turn `turn_a500254a14394c049b80594535fe508f`; + 事件为 `turn.started,message.delta,item.started,item.completed,turn.completed`, + `queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、`reconnect` 与 + `terminal=restored` 均为 `ok`。Gate B 编译期间的 `lower_turn_start_params`、 + `lower_runtime_options` dead-code warning 为既有 App Server 警告,非本切片引入;本轮未 + 触及 Electron/GUI bridge,未运行 `verify:gui-smoke`。总体计划继续保持 `in-progress`, + 下一刀回到 A3 `chatwidget` input/interrupt/turn lifecycle,或继续收敛其它 bottom-pane + current owner。 + +本轮 A3 input-flow owner 收口(2026-09-14): + +- 对照 Codex `chatwidget/input_flow.rs` 的路由边界,Lime 将 App 键盘输入实现迁入 + `tui/src/app/input_flow.rs`,由该模块作为唯一 current owner;全局滚动、Agent 切换、 + collaboration mode、interrupt、图片/复制/Transcript 快捷键、队列编辑和 composer action + 映射均保留既有 App Server/Thread/Turn/Item 主链,不新增第二套状态机或本地存储。 +- 旧 `tui/src/app/input.rs` 已降为无业务逻辑的历史边界文件,且不再由 `app.rs` 注册;生产 + 路径只注册 `mod input_flow`,避免旧实现与 Codex-shaped owner 并存。该旧路径分类为 + `compat/dead-candidate`,后续若结构守卫确认无外部源码 consumer,再按仓库删除政策处理。 +- 本轮未机械复制 Codex 尚无 Lime canonical consumer 的完整 `chatwidget` runtime keymap、 + voice、IDE context、atomic text-element submission 或 transport;这些继续分类为 + `contract/defer`。没有新增协议字段、兼容包装、生产 mock 或第二套后端。 +- 验证:`cargo fmt --manifest-path lime-rs/Cargo.toml --all -- --check`、TUI library + `818/818`、`cargo clippy --locked --manifest-path lime-rs/Cargo.toml -p tui --all-targets + --no-deps -- -D warnings`、`npm run inventory:tui-structure`(`873` 个文件)、 + `npm run test:contracts` 与 `git diff --check` 均通过。未重复运行真实 `smoke:tui-gate-b` + 或 `verify:gui-smoke`;本轮仅重命名/收敛现有输入 owner,之前 Gate B 证据仍有效。总体计划 + 保持 `in-progress`,下一刀优先对照 `chatwidget/input_submission.rs` 与 `turn_lifecycle.rs` + 的 Lime current 子集。 + +本轮 A3 input-submission owner 收口(2026-09-14): + +- 对照 Codex `chatwidget/input_submission.rs`,Lime 新增 + `tui/src/app/input_submission.rs`,承接当前可由 Lime canonical contract 支持的提交边界: + `InputResult` 到 `AppAction` 的映射、Ctrl-C 草稿优先级、queued submission 列表维护、 + 远程/本地图片提取与恢复,以及按 `UserInput` 顺序进行无损队列编辑。真实 transport 执行 + 仍归 `runtime` 与 `AppServerSession`,未在 TUI 复制 provider 或第二套 turn runtime。 +- `app.rs` 不再持有上述实现,只保留状态与全局路由;`input_flow.rs` 通过 current + `map_composer_action` 接入新 owner。旧 `app/input.rs` 继续为空的历史边界,未重新注册。 + Codex 的 atomic text-element、mention binding、shell/provider/auth 和完整 queued steer + 能力因没有 Lime current canonical consumer,保持 `contract/defer`,不伪造协议字段或本地 + history store。 +- 分类:`app/input_submission.rs` 为 `current`;`app/input.rs` 为 `compat/dead-candidate`; + 完整 Codex submission/turn lifecycle 为 `partial/contract-defer`。 +- 验证:TUI library `818/818`、TUI all-targets Clippy `-D warnings`、workspace fmt、 + `git diff --check`、`npm run inventory:tui-structure`(`874` 个文件)和真实 + `npm run smoke:tui-gate-b` 均通过。Gate B 线程 + `01a09f81-90b5-73a1-8253-25927ab60443`、回合 `turn_64bedc90177f48ce8d20b35f9dd5abbb`; + 编译期间的 `lower_turn_start_params`、`lower_runtime_options` dead-code warning 为既有 + App Server 警告。总体计划仍为 `in-progress`,下一刀继续读取 Codex `turn_lifecycle.rs` + 与 Lime projection/app event lifecycle,先收敛不引入第二套状态机的 current 子集。 + +本轮 A3 turn-lifecycle owner 收口(2026-09-14): + +- 直接复制 Codex `chatwidget/turn_lifecycle.rs` 的状态职责到 + `tui/src/app/turn_lifecycle.rs`,保留 `agent_turn_running`、`last_turn_id`、预算受限回合 + 集合、完成标签集合和活动回合计时;Lime 没有 `SleepInhibitor`,因此移除该平台依赖, + 不伪造新的系统休眠控制。 +- `App::start_turn`、`hydrate_thread`、`set_thread_id`、`thread_events::apply_notification` + 和 `active_turn_elapsed` 已统一委托该 owner。回合开始、canonical `turn.completed`、 + reconnect/hydrate 与线程切换都通过同一状态转移更新计时,projection 仍是唯一 + Thread/Turn/Item 事实源。 +- 分类:`app/turn_lifecycle.rs` 与 App 接线属于 `current`;不适用于 Lime 当前协议的 + Codex 睡眠抑制、完整预算/完成标签消费继续 `partial/contract-defer`;没有新增协议、 + runtime、history store、生产 mock 或 compat 包装。 +- 验证:turn-lifecycle 定向测试 `3/3`,TUI library `821/821`,`cargo fmt` 对本切片通过, + `git diff --check` 对本切片通过。Clippy 已编译通过本切片,但全量 `-D warnings` 被工作树 + 既有 `history_cell/session.rs` 的 `clippy::obfuscated_if_else` 阻断;格式检查同样只发现 + 该外部改动,未覆盖或回滚并行修改。`Cargo.lock` 的未关联变更保持原样。总体计划仍为 + `in-progress`,下一刀回到 Codex `interaction.rs` / `interrupts.rs` 或 `tool_lifecycle.rs`。 + +本轮 A3 interrupts policy owner 收口(2026-09-14): + +- 直接抽取 Codex `chatwidget/interaction.rs` 的中断判定子集到 + `tui/src/app/interrupts.rs`,由 `should_interrupt_turn` 统一判断活动 canonical turn 与 + Vim search 护栏;`input_flow.rs` 的 Esc 路由改为消费该 owner。 +- Codex `InterruptManager` 的交互请求队列没有机械复制:Lime 已由 `BottomPane` 负责可见 + 请求队列、`pending_interactive_replay` 负责跨线程/恢复生命周期,新增队列会形成双事实源。 + 因此该部分保持 current owner 不变,未新增协议、runtime、history store、生产 mock 或 + compat 包装。 +- 分类:`app/interrupts.rs` 与 Esc 判定接线属于 `current`;完整 Codex interrupt queue、 + steer-after-interrupt、review interrupt 语义因 Lime contract 不承载,继续 + `partial/contract-defer`。 +- 验证:interrupts 定向测试 `3/3`、TUI library `835` 个测试编译并执行;本次新增与 + 生命周期相关测试均通过。全量测试仍受工作树已有 `view.rs` 改动影响: + `test_backend_renders_remote_images_with_selection_highlight` 与 + `transcript_page_size_tracks_resize` 失败;真实 `smoke:tui-gate-b` 的 `ready` 启动标记也 + 因该 view 改动不再出现。该热区属于并行修改,本轮未覆盖或回滚。格式检查仅剩已有 + `history_cell/session.rs` 排版差异,Clippy 仅剩其既有 `obfuscated_if_else`。总体计划仍为 + `in-progress`,下一刀回到不触碰 view 热区的 Codex `tool_lifecycle` / streaming owner。 + +本轮 A3/S4 composer 视觉收口(2026-09-15): + +- 对照 Codex `bottom_pane/chat_composer.rs` 的输入锚点语义,`view.rs` 移除 composer 的全宽 + 上下边框,改用 `›` prompt、同基线 placeholder 与现有 textarea 状态;空态 placeholder + 使用 `Locale` 五语言文案(英文为 `Ask Lime to do anything`),输入、历史搜索高亮、Vim、 + 光标和 popup 仍复用原 `ChatComposer` owner,不新增第二套 draft 状态。 +- 输入区按 transcript + 活动态 status + queue preview + composer + footer 的弹性布局计算; + idle 不再占用伪状态行,running status 仅在活动回合存在时占高。附件行先绘制,placeholder + 只绘制到文本子区域,避免空草稿覆盖远程/本地图片编号;窄终端继续通过现有高度裁剪保持 + 可见输入区。 +- `locale.rs` 新增 composer/model/queue/footer 相关本地化入口,并补齐 MCP inventory 与自动 + 解析文案的五语言断言;本轮未修改 App Server protocol、RuntimeCore、provider 或持久化。 +- 分类:`view.rs` composer geometry、`locale.rs` placeholder 属于 `current`;Codex 完整 + composer keymap、voice、atomic text-element 与私有 provider/auth 能力仍为 + `partial/contract-defer`,不通过 mock 或本地状态补造。 +- 验证:`cargo fmt --manifest-path lime-rs/Cargo.toml --all -- --check` 通过;TUI library + `845/845` 通过,新增 idle placeholder、transient status 与远程图片选择回归通过, + `git diff --check` 通过。真实 `npm run smoke:tui-gate-b` 默认矩阵通过,覆盖 + complete/queue-edit/agents-overview/focus-palette/resize-reflow/reconnect 与 terminal restore。 + TUI Clippy 仍被并行改动 `history_cell/session.rs` 的既有 + `clippy::obfuscated_if_else` 阻断;本轮未运行 `verify:gui-smoke`,也未将历史 Gate B 证据 + 误作本轮新证据。总体计划保持 `in-progress`,下一刀继续 S4 status/footer 窄屏矩阵或回到 + A3 `tool_lifecycle`/streaming owner。 + +本轮 A3 tool-lifecycle / streaming 边界收口(2026-09-15): + +- 对照 Codex `chatwidget/tool_lifecycle.rs` 与 `chatwidget/streaming.rs`,将 Lime 的 + `ItemStarted/ItemCompleted` 多 Agent 生命周期观察从 `app/thread_events.rs` 拆到唯一的 + `app/tool_lifecycle.rs`;该 owner 只更新既有 `AgentNavigationState` 的 parent-owned 与 + liveness,不创建第二套工具队列、线程状态或 projection。 +- `ReasoningSummaryPartAdded` 以前在 Lime 路由层被丢弃,现由 canonical `ConversationProjection` + 保留 streamed reasoning section 边界;已完成 reasoning item 对迟到通知保持不变,后续 + `item/completed` 仍是权威文本修复点。新增多 Agent lifecycle、reasoning section 和迟到 + 边界回归,协议与 RuntimeCore 不变。 +- 分类:`app/tool_lifecycle.rs` 与 reasoning streaming 边界属于 `current`;Codex 完整 + stream controller、interrupt queue、provider/private realtime 与系统副作用仍为 + `partial/contract-defer`,无 compat 包装、生产 mock 或本地 history store。 +- 验证:`cargo test --manifest-path lime-rs/Cargo.toml -p tui tool_lifecycle`(2/2)、 + `cargo test --manifest-path lime-rs/Cargo.toml -p tui reasoning_summary`(1/1)、 + `cargo fmt --manifest-path lime-rs/Cargo.toml --all -- --check` 通过;结构 inventory 已 + 更新至 878 个文件。完整 TUI/Gate B 未在本刀重复执行;当前工作树既有 `view.rs` 两项 + 测试失败与 `history_cell/session.rs` Clippy 阻塞保持原样,未覆盖并行修改。 + +本轮 A3 streaming 终态护栏补充(2026-09-15): + +- 对照 Codex `chatwidget/streaming.rs` 的 stream flush 语义,`ConversationProjection` 的 + `append_delta` 现在只接受仍处于 streaming 状态且类型匹配的条目;`item/completed` 替换 + 后的迟到 assistant/reasoning/command/plan delta 会被忽略,不再改写 canonical 文本。 +- `TurnCompleted` 的终态收敛同时关闭所有 provisional `streaming` 尾部,即使该条目没有 + `status=Running`;这使中断/失败后到达的旧 delta 不能重新创建可见的活动尾部。canonical + turn item 仍可在后续 `item/completed` 或 turn repair 中正常替换,未新增 item/turn store。 +- 新增 `late_agent_delta_does_not_reopen_completed_item`、迟到 reasoning delta 回归,以及 + `terminal_turn_closes_unrepaired_stream_tail_against_late_delta`;改动仅落在既有 + `projection.rs` owner,分类为 `current`,Codex provider/private stream controller 仍为 + `partial/contract-defer`。 +- 验证:三项定向 projection 测试与 `cargo fmt --manifest-path lime-rs/Cargo.toml --all + -- --check`、目标文件 `git diff --check` 通过;完整 TUI all-targets 继续作为本刀收尾门槛。 + +本轮 A3 streaming 终态集合补充(2026-09-15): + +- `ConversationProjection` 增加轻量 `closed_turn_ids` 集合;hydrate 的已完成/失败/中断回合 + 与实时终态 `turn.completed` 均登记回合身份,所有 assistant/reasoning/plan/command + delta 以及 patch/diff/plan 临时更新在创建或替换前 fail-closed。这样完全未知 item 的迟到 + 通知也不会在终态回合后凭空制造 streaming transcript 行。 +- `TurnStarted` 清除对应旧身份,允许新回合正常建立 provisional item;canonical + `ItemCompleted`/turn repair 仍不受该集合限制,继续作为权威文本来源。未新增 item/turn + store、协议字段、兼容包装或生产 mock。 +- 新增 `terminal_turn_rejects_late_delta_for_unknown_item` 与 + `a_new_turn_can_create_a_streaming_item_after_a_terminal_turn` 回归。该切片属于 `current`, + Codex 私有 stream controller、provider/private realtime 和完整 interrupt queue 仍为 + `partial/contract-defer`。 +- 验证:`projection::tests` 49/49、`cargo fmt --manifest-path lime-rs/Cargo.toml --all + -- --check`、目标文件 `git diff --check` 通过;完整 TUI all-targets 待本刀收尾执行。 + +本轮 A3 reasoning status projection 补充(2026-09-15): + +- 对照 Codex `chatwidget/streaming.rs` 的 `latest_summary_line`,`ConversationProjection` + 增加唯一 reasoning status 投影:活动回合中的最新可用粗体/标题行进入 status row,空行和 + HTML 注释不覆盖已有标题;Hook display message 仍优先,显式 `set_status`、错误和回合终态 + 会清除 reasoning 标题。hydrate、实时 item completion 和 reasoning delta 复用同一提取规则。 +- 该能力只使用现有 `status`/`active_turn_id` 与 canonical reasoning entry,不新增 ChatWidget + 私有状态、provider stream controller、history store 或协议字段;分类为 `current`,完整 + reasoning replay/voice handoff 继续 `partial/contract-defer`。 +- 新增 `reasoning_summary_updates_running_status_with_latest_usable_line` 与 + `explicit_status_clears_reasoning_summary_header` 回归。 +- 验证:projection 定向测试 `52/52`、`cargo fmt --manifest-path lime-rs/Cargo.toml --all + -- --check`、TUI all-targets `clippy -D warnings`、目标文件 `git diff --check` 均通过。 + +本轮 S4 status/footer 窄屏矩阵收口(2026-09-15): + +- `bottom_pane/footer.rs` 对齐 Codex 空闲 footer:无草稿且无活动回合时保留本地化快捷键入口 + (英文 `? for shortcuts`),同时修正右侧 context 超宽时的整段折叠,避免窄屏残词或越界。 +- `view.rs` 与 `status_indicator_widget.rs` 增加五语言 × `40/80/120` 列运行态、队列、hook、 + details 几何回归,确保 transcript/status/queue/composer/footer 五区连续且每行按 display + width 安全截断;不修改 ChatComposer 状态模型。 +- 分类:footer 快捷键提示、status/footer 几何属于 `current`;Codex context 百分比、完整 + statusline 配置和私有 provider 状态因 Lime 无 canonical 事实源,继续 `partial/contract-defer`, + 不通过 mock 或第二状态 owner 补造。 +- 验证:TUI library `853/853`;TUI Clippy `-D warnings`、workspace fmt check、 + `npm run inventory:tui-structure`(878 files)、`git diff --check` 与 + `npm run smoke:tui-gate-b` 均通过。Gate B 真实 PTY/alternate screen/stdio JSON-RPC 事件链为 + `turn.started,message.delta,item.started,item.completed,turn.completed`,并验证 + `queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、`reconnect` 和 + `terminal=restored`。 +- A3/S4 当前退出:composer、status、footer 的 Codex 核心视觉合同已有 Lime owner 与窄屏回归; + 下一刀进入 S5 popup/picker 统一(slash/file/skill/model/agent/approval/request_user_input), + 不扩大到 Codex 私有 account/update/marketplace/storage。 + +本轮 S5 slash popup 锚定收口(2026-09-15): + +- 对照 Codex `bottom_pane/command_popup.rs`,Lime slash command popup 限制为最多 8 个可见行, + 上下导航时移动可见切片,保证选中项始终可见且弹层继续贴合 composer;命令 catalog、输入 + parser 与 App Server contract 均未复制或修改。 +- 选中项使用统一 Lime `accent_style`,描述使用 `muted_style`,每行按终端 display width + 截断;新增长目录 TestBackend 回归验证 72x16 终端中选中行可见与行数上限。 +- 分类:popup geometry/style 属于 `current`;Codex 私有 service-tier/account 命令继续 + `excluded`,不建立 compat 或第二命令状态源。 +- 验证:command popup 定向测试 `6/6` 通过;随后完整 TUI library 达到 `854/854`,Clippy、 + fmt、`git diff --check`、inventory `878 files` 与新一轮真实 `smoke:tui-gate-b` 均通过。 + 最新 Gate B 继续覆盖 `queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、 + `reconnect` 与 `terminal=restored`。 + 下一刀扩展 model/agent/resume picker 及 approval/request_user_input overlay 的窄屏布局。 + +本轮 S5 picker 语义样式补充(2026-09-15): + +- `model_picker.rs` 与 `app/agent_picker.rs` 复用 Lime 语义样式 owner:选中行使用 + `accent_style`,标题、描述和 footer 使用 `muted_style`;不改变 popup 尺寸、导航快捷键或 + App Server catalog。 +- 保留 ModelPicker 现有 Enter 索引合同,同时完成选中态与文案样式收口;navigation/control- + binding 回归与既有 picker 多语言窄屏测试一并通过。 +- 验证:完整 TUI library `862/862`、TUI Clippy `-D warnings`、fmt 与 `git diff --check` 均通过。 + 随后重新运行真实 `npm run smoke:tui-gate-b`,`queue-edit/agents-overview/focus-palette/` + `resize-reflow/reconnect/terminal=restored` 全部通过;下一刀继续 model/agent/resume overlay + 的行数和锚定矩阵,再处理 approval/request_user_input。 + +本轮 A3 streaming/projection 收尾证据(2026-09-15): + +- `npm run inventory:tui-structure` 刷新结构账本为 `878` 个文件;真实 + `npm run smoke:tui-gate-b` 通过,thread `01a0a253-7f84-7670-80a5-0599efba75ed`、turn + `turn_a34330e0fd5f4f358df65145ea90b8c4`,事件为 + `turn.started,message.delta,item.started,item.completed,turn.completed`,并证明 + `queue-edit`、`agents-overview`、`focus-palette`、`resize-reflow`、`reconnect` 与 + `terminal=restored` 均为 `ok`。 +- TUI all-targets 在本轮达到 `852` 通过、`1` 失败;唯一失败为并行既有 + `bottom_pane::footer::tests::queue_hint_shortens_before_it_disappears` 的 + `show_context` 断言,单测复跑仍稳定复现,未触及本轮 projection 写集。该失败不归因于 + 本轮代码,也未覆盖或回滚并行 footer 改动;本轮自身 projection `52/52`、all-targets + `clippy -D warnings`、fmt、Gate B 与 `git diff --check` 均通过。 + +本轮 S5 picker 导航合同补充(2026-09-15): + +- 对照 Codex `bottom_pane/list_selection_view.rs`、`resume_picker.rs` 与 picker 测试, + `ModelPicker`、`AgentPicker`、`resume_picker::PickerState` 的可搜索列表现在保留普通 + `j/k`(以及其它无修饰字符)作为查询输入;上下箭头和 Ctrl-P/N/K/J 才执行列表导航, + resume picker 仍保留 PageUp/PageDown 分页。模型/Agent picker 上下导航按 Codex 列表规则 + 循环,resume picker 的已加载行继续使用原有分页边界,不伪造远端数据。 +- 未映射的 Ctrl/Alt 字符不再污染模型或 resume 查询;command popup 同步接受 Ctrl-P/N/K/J + 导航。所有改动只落在已有 picker/composer owner,没有新增协议字段、第二套 selection + state、runtime 或本地存储。 +- `model_picker` 定向测试 `5/5`、`agent_picker` 定向测试 `4/4`、`command_popup` 定向测试 + `10/10`、resume picker 新增导航/查询回归通过。完整 TUI all-targets 仍需在并行 footer + 热区修复后收口;本轮未覆盖其既有 `queue_hint_shortens_before_it_disappears` 失败。 + +本轮 S5 approval/request_user_input 窄屏合同补充(2026-09-15): + +- `bottom_pane/render.rs` 的高度测量改为使用带上下边框交互面的真实文本宽度;之前错误扣除 + 两列会让窄终端的 CJK/emoji 换行行数被低估。approval 与 request_user_input 选项共同复用 + `selection_row_layout::visible_item_window` 和 `MAX_POPUP_ROWS=8`,长目录导航时窗口跟随 + 选中项,保留原始 option index/提交语义,不增加第二套 selection state。 +- request_user_input 的 Up/K 与 Down/J 在 Options 焦点下按 Codex 列表语义循环;Notes 焦点仍由 + 同一 ChatComposer 处理。footer 在本地化主提示无法容纳时按 `Enter · Esc`、`↵ · Esc`、 + `↵Esc`、`Esc` 逐级压缩,保证取消入口不被中间截断;宽度足够时继续追加已有次要提示。 +- 新增回归:12 项 request_user_input 末项选中时只渲染 8 行且选中行可见;窄宽度高度测量与 + Paragraph 实际行数一致;选项上下导航首尾循环;五语言极窄 footer(3/4/7/12/18 列)不越界 + 且保留 Esc。分类:approval/request_user_input 窄屏窗口、footer 与导航均为 `current`; + Codex 完整 ScrollState、可配置 keymap 和完整 async_questions layout 仍为 `partial/contract-defer`。 +- 验证:定向回归 `3/3`;`cargo test --locked --manifest-path "lime-rs/Cargo.toml" -p tui --all-targets` + `879` library、`16` integration、`1` dependency regression 全部通过; + `cargo clippy --locked --manifest-path "lime-rs/Cargo.toml" -p tui --all-targets --no-deps -- -D warnings`、 + workspace fmt、`git diff --check`、`npm run test:contracts` 均通过。真实 + `npm run smoke:tui-gate-b` 通过,thread `01a0a289-f0d4-7b11-8fb4-cff598078b55`、turn + `turn_7baeb791a896424cacc61e48fcc4ad52`,事件为 + `turn.started,message.delta,item.started,item.completed,turn.completed`,并证明 + `queue-edit/agents-overview/focus-palette/resize-reflow/reconnect/terminal=restored` 均为 `ok`。 + 本轮未触及 Electron/GUI bridge,未运行 `verify:gui-smoke`;workspace 全量 Clippy 仍可能受 + 并行 `agent-protocol` lint 影响,不作为 TUI 写集阻塞。 +- 这一步继续推进 `TUI Host -> App Server JSON-RPC -> RuntimeCore -> canonical + Thread/Turn/Item projection` 主链的可操作交互;下一刀回到 S5 余项(MCP elicitation/统一 + selection footer)或 A2 history/transcript contract,不复制 Codex 私有 runtime/store。 + +本轮 S5 approval/request_user_input 窄屏布局补充(2026-09-15): + +- approval 与 request_user_input 继续复用现有 BottomPane 和 App Server typed request,不新增 + 协议、RuntimeCore 或 mock 状态。approval 选项统一编号和 `accent_style` 选中态,底部 footer + 固定保留 `Enter confirm · Esc cancel`;request_user_input footer 按宽度优先保留 `Enter + submit` 与 `Esc cancel`,次级选择/备注/问题导航提示仅在有空间时出现。 +- 问题标题、说明和选项使用 grapheme-safe 宽度重排;request_user_input 选项窗口最多展示 8 + 项并保证当前选择可见,编辑输入行保持单行截断,光标定位跟随重排后的实际输入行。 +- `view.rs` 新增五语言 × `40/80/120` 列审批/问答 TestBackend 回归,覆盖标题、选项、主 + 操作和行宽边界;locale 新增 overlay controls 全语言文案回归。 +- 分类:窄屏 approval/request_user_input presentation 属于 `current`;Codex guardian、 + account 与未映射的完整 async_questions 字段保持 `contract/defer`,无 compat/mock fallback。 + +验证:TUI library `879 passed`、TUI Clippy `-D warnings`、workspace fmt check、 +`git diff --check`、`npm run inventory:tui-structure`(878 files)与真实 +`npm run smoke:tui-gate-b` 均通过;Gate B 事件链为 +`turn.started,message.delta,item.started,item.completed,turn.completed`,并继续覆盖 +`queue-edit/agents-overview/focus-palette/resize-reflow/reconnect/terminal=restored`。App Server +仍有既有 `lower_turn_start_params`/`lower_runtime_options` dead-code warning,不影响本刀。 + +本轮 S5 resume picker 窄屏矩阵补充(2026-09-15): + +- 对照 Codex `resume_picker.rs` 的搜索行、选中行和 bounded list 语义,resume picker 标题 + 收敛为单一动作标题,列表选中标记由 row owner 显式绘制 `❯ `,普通行预留同等宽度,避免 + ratatui `List` 内建高亮符号造成行首跳动;展开详情按扣除标记后的实际宽度渲染。 +- 垂直布局改为从终端高度动态分配 header/search/list/footer,极短终端下 footer 不再把列表 + 推出可视区域;metadata、transcript loading/failed/empty 提示和标题均采用 grapheme-safe + 截断,宽度为 0 时 fail-closed。 +- 新增 TestBackend 回归覆盖 `zh-CN`、`zh-TW`、`en-US`、`ja-JP`、`ko-KR` × `40/80/120` + 列,组合长标题、长路径、长搜索词、选中末项和展开 transcript,确认渲染行数、选中行和 + Unicode cell 均保持在终端边界内。 +- 分类:resume picker 的动作标题、列表 marker、动态 chrome 和窄屏截断属于 `current`; + Codex 私有 state DB fallback、provider/account 过滤与完整 toolbar 持久化继续 + `contract/defer`,不建立本地 history store 或 compat 路径。 + +验证:resume picker 定向回归 `41/41`;TUI library `882/882`;TUI Clippy `-D warnings`、 +workspace fmt、`git diff --check`、`npm run inventory:tui-structure`(878 files)和真实 +`npm run smoke:tui-gate-b` 均通过。Gate B 事件链为 +`turn.started,message.delta,item.started,item.completed,turn.completed`,并继续覆盖 +`queue-edit/agents-overview/focus-palette/resize-reflow/reconnect/terminal=restored`。 +本轮未触及 Electron/GUI bridge,未运行 `verify:gui-smoke`。下一刀继续 S5 统一 MCP +elicitation/selection footer,或扩大 A2 history/transcript contract 证据。 + +本轮 S5 MCP elicitation 窄屏补充(2026-09-15): + +- `bottom_pane/mcp_server_elicitation.rs` 继续作为唯一交互 owner,复用 App Server typed + elicitation request 和既有 response mapping;不新增协议、RuntimeCore、provider 或本地 + 状态源。 +- MCP 选项保持 8 行 bounded viewport,footer 在窄宽度逐级压缩且保留 `Esc`;文本输入在 + 宽度投影前先按显式换行拆成物理行,再进行 grapheme-safe 截断,确保多行 CJK/emoji 草稿的 + 可见行与光标坐标一致。 +- 新增 12 列 TestBackend 回归,覆盖中文、emoji、显式换行、输入行宽度和光标物理行;现有 + 五语言 controls、长选项 bounded window 与极窄 footer 回归继续有效。 +- 本切片分类为 `current` presentation/interaction;Codex 私有 async_questions、完整 + keymap/guardian/account 与未映射 MCP transport 继续 `partial/contract-defer`,不恢复任何 + retired runtime/store/fallback。 + +验证:MCP elicitation 定向测试 `11/11`;已执行 `cargo fmt --all`、`git diff --check`。完整 +TUI all-targets、Clippy、结构 inventory 与真实 `npm run smoke:tui-gate-b` 作为本轮收尾门禁 +继续执行;未触及 Electron/GUI bridge,因此不运行 `verify:gui-smoke`。 + +本轮 S6 streaming pager anchor 补充(2026-09-15): + +- `pager_overlay.rs` 的 transcript 手动滚动现在识别 streaming active line 原地替换:在 + canonical 行数量不变、且存在共同前缀/后缀(或单行 transcript)时,保留原逻辑行索引, + 再按当前宽度计算新的 wrapped height。delta 增长不会把用户锚点留在旧的视觉行号。 +- 没有共同邻接行的同长度 hydrate/replacement 继续 fail-closed;prepend history、width + reflow、底部 pinned 和 overlay 生命周期沿用既有 owner。没有新增 protocol/runtime/store + 或生产 mock。 +- 新增 `transcript_overlay_remaps_manual_anchor_when_streaming_line_grows_in_place` TestBackend + 回归,锁定 canonical anchor 在 streaming redraw 后保持不变。 + +分类:S6 streaming pager anchor 为 `current`;主聊天 terminal-native scrollback 的完整 +source-backed reflow、Codex live-cell commit 与跨 runtime VT100 contract 仍为 +`partial/contract/defer`。 + +验证:pager 定向测试 `13/13`;TUI all-targets `884` library、`16` integration、`1` +dependency regression;TUI Clippy `-D warnings`;workspace fmt;`git diff --check`; +`npm run inventory:tui-structure`(878 files);真实 `npm run smoke:tui-gate-b` 均通过。 +Gate B 事件仍为 `turn.started,message.delta,item.started,item.completed,turn.completed`, +并证明 `queue-edit/agents-overview/focus-palette/resize-reflow/reconnect/terminal=restored`。 +本轮未触及 Electron/GUI bridge,因此不运行 `verify:gui-smoke`。 + +本轮 S6 主视图 viewport anchor 补充(2026-09-15): + +- `transcript_reflow.rs` 新增唯一 `TranscriptViewport` owner,主 `view.rs` 通过它把 + `App::transcript_scroll` 的距底部请求解析为 canonical wrapped-row offset。上一帧行内容、 + 宽度和实际 offset 会在 streaming tail 增长、active line 原地替换或 width reflow 时重映射, + 手动滚动不再因新 delta 重新落到尾部。 +- 距底部为 0 的 pinned 语义继续跟随新内容;thread 切换与 `hydrate_thread` 清除 viewport + 锚点并回到底部,防止跨会话污染。所有数据仍来自 App Server canonical Thread/Turn/Item + projection,没有新增协议、runtime、history store 或生产 mock。 +- 新增 `TranscriptViewport` 回归覆盖 streaming 尾部增长、宽度 reflow、pinned tail-follow, + 以及 thread 切换回到底部;旧 pager overlay anchor 规则继续保留并独立覆盖 overlay 场景。 + +分类:主视图 scroll anchor、streaming tail-follow 和 width reflow 为 `current`;terminal-native +scrollback 的完整 source-backed rebuild、Codex live-cell commit 与跨 runtime VT100 contract +仍为 `partial/contract/defer`。 + +验证:`transcript_reflow` 定向测试 `6/6`;TUI all-targets `888` library、`16` integration、 +`1` dependency regression;TUI Clippy `-D warnings`;workspace fmt;`git diff --check`; +`npm run inventory:tui-structure`(878 files);真实 `npm run smoke:tui-gate-b` 均通过。Gate B +事件仍为 `turn.started,message.delta,item.started,item.completed,turn.completed`,并证明 +`queue-edit/agents-overview/focus-palette/resize-reflow/reconnect/terminal=restored`。本轮未触及 +Electron/GUI bridge,因此不运行 `verify:gui-smoke`。 diff --git a/internal/exec-plans/tui-structure-inventory.json b/internal/exec-plans/tui-structure-inventory.json index 14b1bbe2f..d1f05fd18 100644 --- a/internal/exec-plans/tui-structure-inventory.json +++ b/internal/exec-plans/tui-structure-inventory.json @@ -66321,7 +66321,7 @@ }, "lime-rs/crates/tui/src": { "root": "/Users/coso/Documents/dev/ai/aiclientproxy/lime/lime-rs/crates/tui/src", - "fileCount": 160, + "fileCount": 174, "files": [ "app.rs", "app/agent_navigation.rs", @@ -66340,6 +66340,9 @@ "app/history_pagination.rs", "app/history_ui.rs", "app/input.rs", + "app/input_flow.rs", + "app/input_submission.rs", + "app/interrupts.rs", "app/pending_interactive_replay.rs", "app/reconnect.rs", "app/replay_filter.rs", @@ -66351,14 +66354,19 @@ "app/thread_event_buffer_tests.rs", "app/thread_events.rs", "app/thread_settings.rs", + "app/tool_lifecycle.rs", "app/transcript_export.rs", + "app/turn_lifecycle.rs", "app/working_directory.rs", "app_server_session.rs", "app_server_session/history.rs", "app_server_session/history_tests.rs", "bottom_pane/approval_overlay.rs", "bottom_pane/chat_composer.rs", + "bottom_pane/chat_composer/agents_navigation.rs", "bottom_pane/chat_composer/attachment_state.rs", + "bottom_pane/chat_composer/completion_target.rs", + "bottom_pane/chat_composer/completion_target_tests.rs", "bottom_pane/chat_composer/draft_state.rs", "bottom_pane/chat_composer/file_search_popup.rs", "bottom_pane/chat_composer/footer_state.rs", @@ -66373,13 +66381,16 @@ "bottom_pane/chat_composer/vim_history_tests.rs", "bottom_pane/chat_composer/vim_search.rs", "bottom_pane/chat_composer/vim_search_tests.rs", + "bottom_pane/command_popup.rs", "bottom_pane/footer.rs", "bottom_pane/mcp_server_elicitation.rs", "bottom_pane/mod.rs", + "bottom_pane/pending_input_preview.rs", "bottom_pane/render.rs", "bottom_pane/request_user_input/mod.rs", "bottom_pane/request_user_input/render.rs", "bottom_pane/request_user_input/render_tests.rs", + "bottom_pane/selection_row_layout.rs", "bottom_pane/textarea.rs", "bottom_pane/textarea/hyperlinks.rs", "bottom_pane/textarea/hyperlinks_tests.rs", @@ -66459,6 +66470,9 @@ "slash_command.rs", "status_indicator.rs", "status_indicator_widget.rs", + "status_indicator_widget/timer.rs", + "status_indicator_widget/timer_tests.rs", + "style.rs", "table_detect.rs", "terminal_hyperlinks.rs", "terminal_hyperlinks/paragraph.rs", @@ -66484,13 +66498,8 @@ "width.rs", "wrapping.rs" ], - "symbolCount": 2705, + "symbolCount": 3038, "symbols": [ - { - "path": "app.rs", - "name": "active_turn_elapsed", - "kind": "function" - }, { "path": "app.rs", "name": "adjacent_agent", @@ -66506,11 +66515,6 @@ "name": "AppAction", "kind": "type" }, - { - "path": "app.rs", - "name": "attach_image", - "kind": "function" - }, { "path": "app.rs", "name": "begin_startup_input_boundary", @@ -66566,11 +66570,6 @@ "name": "hydrate_thread", "kind": "function" }, - { - "path": "app.rs", - "name": "map_composer_action", - "kind": "function" - }, { "path": "app.rs", "name": "normalize_paste", @@ -66581,11 +66580,21 @@ "name": "note_startup_protected_request", "kind": "function" }, + { + "path": "app.rs", + "name": "open_mcp_inventory", + "kind": "function" + }, { "path": "app.rs", "name": "open_status_pager", "kind": "function" }, + { + "path": "app.rs", + "name": "pre_draw_tick", + "kind": "function" + }, { "path": "app.rs", "name": "release_startup_input_boundary_if_ready", @@ -66606,16 +66615,6 @@ "name": "reset_external_editor_state", "kind": "function" }, - { - "path": "app.rs", - "name": "restore_pending_images", - "kind": "function" - }, - { - "path": "app.rs", - "name": "restore_queued_submission_for_edit", - "kind": "function" - }, { "path": "app.rs", "name": "restore_thread_input", @@ -66661,26 +66660,11 @@ "name": "set_locale", "kind": "function" }, - { - "path": "app.rs", - "name": "set_queued_submissions", - "kind": "function" - }, - { - "path": "app.rs", - "name": "set_remote_image_urls", - "kind": "function" - }, { "path": "app.rs", "name": "set_thread_id", "kind": "function" }, - { - "path": "app.rs", - "name": "start_turn", - "kind": "function" - }, { "path": "app.rs", "name": "status_value", @@ -66691,21 +66675,6 @@ "name": "sync_command_popup", "kind": "function" }, - { - "path": "app.rs", - "name": "take_pending_images", - "kind": "function" - }, - { - "path": "app.rs", - "name": "take_remote_image_urls", - "kind": "function" - }, - { - "path": "app.rs", - "name": "upsert_queued_submission", - "kind": "function" - }, { "path": "app/agent_navigation.rs", "name": "active_agent_label", @@ -66876,6 +66845,11 @@ "name": "is_empty", "kind": "function" }, + { + "path": "app/agent_picker.rs", + "name": "long_agent_catalog_keeps_selected_row_inside_bounded_popup", + "kind": "function" + }, { "path": "app/agent_picker.rs", "name": "picker", @@ -66891,6 +66865,11 @@ "name": "picker_escape_cancels_and_narrow_render_stays_bounded", "kind": "function" }, + { + "path": "app/agent_picker.rs", + "name": "picker_navigation_wraps_and_accepts_control_bindings", + "kind": "function" + }, { "path": "app/agent_picker.rs", "name": "picker_selects_rows_in_stable_navigation_order", @@ -67006,6 +66985,11 @@ "name": "overview_render_shows_localized_new_task_input_without_overflow", "kind": "function" }, + { + "path": "app/agents_overview_render.rs", + "name": "overview_render_uses_selection_row_for_status_and_current_context", + "kind": "function" + }, { "path": "app/agents_overview_render.rs", "name": "render", @@ -67311,6 +67295,11 @@ "name": "unsuccessful_and_running_turns_do_not_create_completion_boundaries", "kind": "function" }, + { + "path": "app/history_pagination.rs", + "name": "answer", + "kind": "function" + }, { "path": "app/history_pagination.rs", "name": "handle_older_history_page", @@ -67321,11 +67310,56 @@ "name": "handle_older_history_page_with_turns", "kind": "function" }, + { + "path": "app/history_pagination.rs", + "name": "initial_history_page_uses_turn_metadata_for_filtering_and_completion", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "initial_history_page_without_turn_metadata_stays_item_only", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "older_page_reconciles_nested_review_prompt_from_adjacent_turn_metadata", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "older_pagination_completion_footers_follow_answers_without_overlap_duplicates", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "prepend_history_page", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "prepend_initial_history_page", + "kind": "function" + }, + { + "path": "app/history_pagination.rs", + "name": "request_all_older_history_pages", + "kind": "function" + }, { "path": "app/history_pagination.rs", "name": "request_older_history_page", "kind": "function" }, + { + "path": "app/history_pagination.rs", + "name": "user_message", + "kind": "function" + }, + { + "path": "app/history_ui.rs", + "name": "empty_thread_starts_with_exactly_one_session_header", + "kind": "function" + }, { "path": "app/history_ui.rs", "name": "render_transcript_content_lines", @@ -67357,10 +67391,75 @@ "kind": "function" }, { - "path": "app/input.rs", + "path": "app/input_flow.rs", "name": "handle_key_event", "kind": "function" }, + { + "path": "app/input_submission.rs", + "name": "attach_image", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "map_composer_action", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "restore_pending_images", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "restore_queued_submission_for_edit", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "set_queued_submissions", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "set_remote_image_urls", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "take_pending_images", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "take_remote_image_urls", + "kind": "function" + }, + { + "path": "app/input_submission.rs", + "name": "upsert_queued_submission", + "kind": "function" + }, + { + "path": "app/interrupts.rs", + "name": "active_turn_can_interrupt", + "kind": "function" + }, + { + "path": "app/interrupts.rs", + "name": "idle_app_does_not_interrupt", + "kind": "function" + }, + { + "path": "app/interrupts.rs", + "name": "should_interrupt_turn", + "kind": "function" + }, + { + "path": "app/interrupts.rs", + "name": "vim_search_suppresses_interrupt", + "kind": "function" + }, { "path": "app/pending_interactive_replay.rs", "name": "clear", @@ -67876,6 +67975,16 @@ "name": "dispatch_disconnected_input", "kind": "function" }, + { + "path": "app/tests.rs", + "name": "empty_composer_left_opens_agents_overview_when_local_navigation_is_enabled", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "empty_composer_left_stays_in_editor_when_navigation_is_disabled", + "kind": "function" + }, { "path": "app/tests.rs", "name": "escape_interrupts_only_an_active_turn_and_preserves_the_draft", @@ -67906,6 +68015,16 @@ "name": "image_shortcut_attaches_and_allows_image_only_submission", "kind": "function" }, + { + "path": "app/tests.rs", + "name": "mcp_slash_command_rejects_unknown_arguments_with_localized_usage", + "kind": "function" + }, + { + "path": "app/tests.rs", + "name": "mcp_slash_commands_request_the_matching_inventory_detail", + "kind": "function" + }, { "path": "app/tests.rs", "name": "mcp_startup_status_is_app_scoped_and_clears_when_ready", @@ -68001,6 +68120,11 @@ "name": "subagents_slash_command_opens_the_codex_named_picker", "kind": "function" }, + { + "path": "app/tests.rs", + "name": "switching_threads_resets_the_main_transcript_to_the_tail", + "kind": "function" + }, { "path": "app/tests.rs", "name": "tab_queues_a_follow_up_without_submitting_the_active_turn", @@ -68011,6 +68135,11 @@ "name": "to_tui_event", "kind": "function" }, + { + "path": "app/tests.rs", + "name": "transcript_overlay_requests_older_history_only_when_session_has_more_pages", + "kind": "function" + }, { "path": "app/tests.rs", "name": "turn_completion_clears_the_active_status_timer", @@ -68136,11 +68265,6 @@ "name": "note_outbound_response", "kind": "function" }, - { - "path": "app/thread_events.rs", - "name": "observe_item", - "kind": "function" - }, { "path": "app/thread_events.rs", "name": "observe_notification", @@ -68246,6 +68370,21 @@ "name": "sync_default_collaboration_mode", "kind": "function" }, + { + "path": "app/tool_lifecycle.rs", + "name": "observe_item", + "kind": "function" + }, + { + "path": "app/tool_lifecycle.rs", + "name": "spawn_marks_receiver_thread_as_parent_owned", + "kind": "function" + }, + { + "path": "app/tool_lifecycle.rs", + "name": "sub_agent_activity_updates_navigation_liveness", + "kind": "function" + }, { "path": "app/transcript_export.rs", "name": "empty_transcript_is_rejected", @@ -68386,6 +68525,76 @@ "name": "write_transcript", "kind": "function" }, + { + "path": "app/turn_lifecycle.rs", + "name": "active_turn_elapsed", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "budget_limited_turn_ids_are_consumed", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "elapsed", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "finish", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "mark_budget_limited", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "projection_transition_starts_and_finishes_timer", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "reset_thread", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "restore_running", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "start", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "start_and_finish_update_running_state", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "start_turn", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "sync_projection_turn", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "take_budget_limited", + "kind": "function" + }, + { + "path": "app/turn_lifecycle.rs", + "name": "TurnLifecycleState", + "kind": "type" + }, { "path": "app/working_directory.rs", "name": "sync_server_cwd", @@ -68461,6 +68670,11 @@ "name": "list_collaboration_modes", "kind": "function" }, + { + "path": "app_server_session.rs", + "name": "list_mcp_server_statuses", + "kind": "function" + }, { "path": "app_server_session.rs", "name": "list_models", @@ -68671,6 +68885,11 @@ "name": "hydrate_initial_thread_history", "kind": "function" }, + { + "path": "app_server_session/history.rs", + "name": "InitialHistoryPage", + "kind": "type" + }, { "path": "app_server_session/history.rs", "name": "initialize_history_pagination", @@ -68726,6 +68945,11 @@ "name": "thread_items_page_params_use_codex_descending_shape", "kind": "function" }, + { + "path": "app_server_session/history_tests.rs", + "name": "turn_lookup_does_not_treat_an_unrelated_turn_as_the_item_page_target", + "kind": "function" + }, { "path": "app_server_session/history_tests.rs", "name": "turn_lookup_keeps_paging_when_target_is_oldest_loaded_turn", @@ -68741,6 +68965,11 @@ "name": "turn_lookup_returns_chronological_order_for_history_filtering", "kind": "function" }, + { + "path": "bottom_pane/approval_overlay.rs", + "name": "approval_navigation_wraps_and_accepts_control_bindings", + "kind": "function" + }, { "path": "bottom_pane/approval_overlay.rs", "name": "ApprovalOverlay", @@ -68876,6 +69105,16 @@ "name": "attach_image", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "attachment_edit_exits_history_navigation", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "attachment_only_draft_does_not_enter_text_history", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "ChatComposer", @@ -68971,6 +69210,11 @@ "name": "desired_height", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "disconnected_submit_keys_clear_history_recall_state", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "dollar_token_range_is_utf8_safe_and_rejects_embedded_prefixes", @@ -69061,6 +69305,16 @@ "name": "has_selected_remote_image", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "history_navigation_clears_completion_popups_for_recalled_text", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "history_navigation_does_not_replace_an_arbitrary_draft", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "history_next", @@ -69071,11 +69325,6 @@ "name": "history_previous", "kind": "function" }, - { - "path": "bottom_pane/chat_composer.rs", - "name": "history_restores_the_unsent_draft", - "kind": "function" - }, { "path": "bottom_pane/chat_composer.rs", "name": "history_search_active", @@ -69126,11 +69375,6 @@ "name": "insert", "kind": "function" }, - { - "path": "bottom_pane/chat_composer.rs", - "name": "is_common_shell_variable", - "kind": "function" - }, { "path": "bottom_pane/chat_composer.rs", "name": "is_empty", @@ -69261,6 +69505,11 @@ "name": "shell_parameters_do_not_open_skill_popup_without_an_exact_skill", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "should_handle_history_navigation", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "should_handle_vim_insert_escape", @@ -69351,6 +69600,11 @@ "name": "take_submission", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "taking_attachment_state_exits_history_navigation", + "kind": "function" + }, { "path": "bottom_pane/chat_composer.rs", "name": "test_skill", @@ -69381,6 +69635,56 @@ "name": "vim_mode_indicator_span", "kind": "function" }, + { + "path": "bottom_pane/chat_composer.rs", + "name": "wrapped_single_line_vertical_navigation_stays_in_editor_until_visual_boundary", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "agents_navigation_available", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left_key_returns_open_agents_overview_only_when_available", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left_navigation_does_not_steal_active_vim_search", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left_navigation_does_not_steal_popup_or_vim_operator_input", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left_navigation_is_disabled_by_default_and_for_remote_sessions", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "left_navigation_requires_an_empty_unblocked_composer", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "modified_left_stays_in_the_editor", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/agents_navigation.rs", + "name": "set_agents_navigation_enabled", + "kind": "function" + }, { "path": "bottom_pane/chat_composer/attachment_state.rs", "name": "attach", @@ -69466,6 +69770,116 @@ "name": "take_remote_image_urls", "kind": "function" }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "current_prefixed_token_range", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "current_prefixed_token_range_with_dollar_predicate", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "dollar_query_is_completable", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "dollar_query_kind", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "DollarQueryKind", + "kind": "type" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "is_common_env_var", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "is_horizontal_whitespace", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "is_mention_name_char", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "prefixed_candidate", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "safe_cursor", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target.rs", + "name": "whitespace_delimited_range", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_accepts_utf8_cursor_boundaries", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_affinity_does_not_cross_line_break", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_affinity_does_not_cross_separator_before_plain_text", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_does_not_cross_trailing_horizontal_whitespace_into_plain_text", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_handles_cursor_inside_utf8_codepoint_fail_closed", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_keeps_nested_dollar_prefix_in_same_token", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_prefers_right_at_token_start", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "current_prefixed_token_preserves_separator_affinity_between_sigil_targets", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "dollar_query_classifies_shell_and_skill_syntax", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "dollar_query_classifies_shell_parameter_edge_cases", + "kind": "function" + }, + { + "path": "bottom_pane/chat_composer/completion_target_tests.rs", + "name": "textarea", + "kind": "function" + }, { "path": "bottom_pane/chat_composer/draft_state.rs", "name": "bytes", @@ -70006,16 +70420,151 @@ "name": "vim_search_query_and_paste_do_not_mutate_the_draft", "kind": "function" }, + { + "path": "bottom_pane/command_popup.rs", + "name": "CommandPopup", + "kind": "type" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "CommandPopupAction", + "kind": "type" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "commands", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "control_navigation_matches_codex_list_bindings", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "empty_filter_keeps_catalog_order_and_narrow_render_is_bounded", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "enter_executes_immediate_commands_and_completes_argument_commands", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "filters_by_prefix_and_wraps_selection", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "for_composer", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "handle_event", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "long_catalog_keeps_selected_row_visible_with_a_bounded_popup", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "matches", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "non_key_and_key_release_events_do_not_change_popup_state", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "render", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "selected", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "test_backend_renders_commands_and_localized_descriptions", + "kind": "function" + }, + { + "path": "bottom_pane/command_popup.rs", + "name": "update", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "active_agent_context", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "active_draft_prefers_queue_hint_and_hides_context_when_narrow", + "kind": "function" + }, { "path": "bottom_pane/footer.rs", "name": "append_vim_indicator", "kind": "function" }, + { + "path": "bottom_pane/footer.rs", + "name": "can_show_left_with_context", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "cjk_and_emoji_context_widths_do_not_overlap_left_hint", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "idle_footer_drops_ambient_agent_context_as_one_unit_when_it_cannot_fit", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "idle_footer_keeps_localized_shortcut_entry_point_across_supported_widths", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "left_fits", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "queue_and_draft_footer_hints_cover_all_product_locales", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "queue_hint_shortens_before_it_disappears", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "render_context_right", + "kind": "function" + }, { "path": "bottom_pane/footer.rs", "name": "render_footer", "kind": "function" }, + { + "path": "bottom_pane/footer.rs", + "name": "render_summary_footer", + "kind": "function" + }, { "path": "bottom_pane/footer.rs", "name": "rendered_text", @@ -70051,6 +70600,26 @@ "name": "renders_vim_search_query_before_submission", "kind": "function" }, + { + "path": "bottom_pane/footer.rs", + "name": "right_aligned_x", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "single_line_footer_layout", + "kind": "function" + }, + { + "path": "bottom_pane/footer.rs", + "name": "SummaryHintKind", + "kind": "type" + }, + { + "path": "bottom_pane/footer.rs", + "name": "SummaryLeft", + "kind": "type" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "advance_or_submit", @@ -70076,6 +70645,21 @@ "name": "commit_current_field", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "compact_control_segment", + "kind": "function" + }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "contains_cancel_hint", + "kind": "function" + }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "contains_submit_hint", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "current_options", @@ -70106,6 +70690,11 @@ "name": "field_value", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "footer_control_lines", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "form_surface_localizes_generated_labels_and_controls", @@ -70136,11 +70725,6 @@ "name": "handle_select_key", "kind": "function" }, - { - "path": "bottom_pane/mcp_server_elicitation.rs", - "name": "input_line_offset", - "kind": "function" - }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "is_complete", @@ -70151,6 +70735,16 @@ "name": "is_empty_object_schema", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "is_input_line", + "kind": "function" + }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "is_option_line", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "is_select_field", @@ -70171,11 +70765,21 @@ "name": "key", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "line_to_owned", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "lines_with_locale", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "lines_with_locale_inner", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "lines_with_locale_with_width", @@ -70221,6 +70825,21 @@ "name": "move_field", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "narrow_footer_wraps_actions_in_priority_order_for_all_locales", + "kind": "function" + }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "narrow_multiline_text_keeps_physical_rows_and_cursor_aligned", + "kind": "function" + }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "narrow_select_surface_bounds_rows_and_keeps_cancel_visible", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "optional_string_property", @@ -70301,6 +70920,11 @@ "name": "set_cursor_position", "kind": "function" }, + { + "path": "bottom_pane/mcp_server_elicitation.rs", + "name": "split_input_lines", + "kind": "function" + }, { "path": "bottom_pane/mcp_server_elicitation.rs", "name": "string_property", @@ -70346,6 +70970,11 @@ "name": "fail_closed", "kind": "function" }, + { + "path": "bottom_pane/mod.rs", + "name": "footer_hint", + "kind": "function" + }, { "path": "bottom_pane/mod.rs", "name": "from_server_request", @@ -70376,6 +71005,16 @@ "name": "key", "kind": "function" }, + { + "path": "bottom_pane/mod.rs", + "name": "next_frame_delay", + "kind": "function" + }, + { + "path": "bottom_pane/mod.rs", + "name": "next_frame_delay", + "kind": "function" + }, { "path": "bottom_pane/mod.rs", "name": "non_interactive_responses_fail_closed_for_every_supported_interaction", @@ -70386,6 +71025,16 @@ "name": "PendingInteraction", "kind": "type" }, + { + "path": "bottom_pane/mod.rs", + "name": "pre_draw_tick", + "kind": "function" + }, + { + "path": "bottom_pane/mod.rs", + "name": "pre_draw_tick", + "kind": "function" + }, { "path": "bottom_pane/mod.rs", "name": "queues_requests_and_resolves_them_in_arrival_order", @@ -70401,9 +71050,69 @@ "name": "unsupported_requests_return_for_rejection", "kind": "function" }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "can_restore_submission", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "desired_height", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "edit_hint_requires_a_lossless_composer_projection", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "line_text", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "narrow_preview_wraps_and_caps_each_submission", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "preview_lines", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "preview_uses_canonical_multimodal_queue_and_caps_visible_items", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "queue_header_and_image_label_cover_all_product_locales", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "render", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "submission", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "submission_preview_lines", + "kind": "function" + }, + { + "path": "bottom_pane/pending_input_preview.rs", + "name": "submission_summary", + "kind": "function" + }, { "path": "bottom_pane/render.rs", - "name": "desired_height_with_locale", + "name": "desired_height_with_locale_for_width", "kind": "function" }, { @@ -70411,6 +71120,16 @@ "name": "lines_with_locale", "kind": "function" }, + { + "path": "bottom_pane/render.rs", + "name": "long_request_input_keeps_the_selected_option_inside_the_eight_row_window", + "kind": "function" + }, + { + "path": "bottom_pane/render.rs", + "name": "narrow_height_uses_the_same_width_as_rendered_content", + "kind": "function" + }, { "path": "bottom_pane/render.rs", "name": "option_line", @@ -70421,11 +71140,36 @@ "name": "render_with_locale", "kind": "function" }, + { + "path": "bottom_pane/render.rs", + "name": "request_with_options", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "auto_resolution_countdown_text", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "auto_resolution_timing_at", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "AutoResolutionTiming", + "kind": "type" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "cancel", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "clear_notes_and_focus_options", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "collects_option_and_freeform_questions_before_responding", @@ -70436,6 +71180,11 @@ "name": "commit", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "control_j_and_k_navigate_options_without_opening_notes", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "ctrl_c_returns_an_empty_fail_closed_response", @@ -70461,11 +71210,31 @@ "name": "finish", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "footer_hint", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "footer_hint_for_width", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "format_auto_resolution_remaining", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "handle_key_event", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "handle_paste", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "has_options", @@ -70476,21 +71245,81 @@ "name": "key", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "move_option", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "move_question", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "narrow_footer_keeps_submit_and_cancel_before_secondary_hints", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "new", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "next_frame_delay", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "non_blocking_request", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "non_blocking_request_expires_with_empty_answers", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "non_blocking_request_is_snoozed_by_user_input", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "non_blocking_request_uses_hidden_grace_then_visible_countdown", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "notes_focus_returns_to_options_without_submitting_on_escape_or_empty_backspace", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "option_count", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "option_navigation_wraps_at_both_ends", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "option_notes_follow_codex_answer_shape", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "options_typing_does_not_open_notes", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "other_enter_opens_notes_before_submitting_custom_answer", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "other_option_enabled", @@ -70501,16 +71330,46 @@ "name": "other_option_is_only_added_when_the_contract_enables_it", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "pre_draw_tick", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "question_navigation_preserves_each_question_draft_and_selection", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "RequestUserInputOverlay", "kind": "type" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "restore_current_state", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "save_current_state", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/mod.rs", "name": "selected_option_label", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "snooze_auto_resolution", + "kind": "function" + }, + { + "path": "bottom_pane/request_user_input/mod.rs", + "name": "ultra_narrow_footer_keeps_cancel_action_visible_without_overflow", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/render.rs", "name": "BreakPoint", @@ -70518,7 +71377,7 @@ }, { "path": "bottom_pane/request_user_input/render.rs", - "name": "lines_with_locale", + "name": "is_option_line", "kind": "function" }, { @@ -70531,11 +71390,21 @@ "name": "lines_with_locale_with_width", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/render.rs", + "name": "lines_with_locale_with_width_at", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/render.rs", "name": "option_line", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/render.rs", + "name": "own_line", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/render.rs", "name": "set_cursor_position", @@ -70546,6 +71415,11 @@ "name": "truncate_line_word_boundary_with_ellipsis", "kind": "function" }, + { + "path": "bottom_pane/request_user_input/render.rs", + "name": "wrap_request_lines", + "kind": "function" + }, { "path": "bottom_pane/request_user_input/render_tests.rs", "name": "halfwidth_sound_marks_are_truncated_and_rendered_at_a_grapheme_boundary", @@ -70556,6 +71430,96 @@ "name": "halfwidth_sound_marks_are_truncated_at_a_grapheme_boundary", "kind": "function" }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "build_full_line", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "build_name_spans", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "centered_popup", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "centered_popup_is_clamped_and_keeps_terminal_anchor", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "combined_description", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "disabled_reason_is_visible_and_width_bounded", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "line_to_owned", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "narrow_rows_stack_description_without_splitting_graphemes", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "new", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "SelectionDescriptionLayout", + "kind": "type" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "SelectionRow", + "kind": "type" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "should_stack", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "stacked_description", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "visible_item_window", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "visible_item_window_keeps_selection_inside_eight_row_viewport", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "wide_rows_align_description_with_display_width", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "wrap_row", + "kind": "function" + }, + { + "path": "bottom_pane/selection_row_layout.rs", + "name": "wrap_stacked_row", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "beginning_of_previous_word", @@ -70651,6 +71615,11 @@ "name": "desired_height", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "editor_display_width", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "effective_scroll", @@ -70696,6 +71665,11 @@ "name": "is_empty", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "is_vertical_boundary", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "is_word_separator", @@ -70761,11 +71735,21 @@ "name": "move_right", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "move_to_display_col_on_line", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "move_up", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "move_visual_vertical", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "nearest_char_boundary", @@ -70791,6 +71775,11 @@ "name": "previous_grapheme_start", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "previous_grapheme_start_at", + "kind": "function" + }, { "path": "bottom_pane/textarea.rs", "name": "remove_next_grapheme", @@ -70866,6 +71855,11 @@ "name": "take", "kind": "function" }, + { + "path": "bottom_pane/textarea.rs", + "name": "Target", + "kind": "type" + }, { "path": "bottom_pane/textarea.rs", "name": "text", @@ -71466,6 +72460,21 @@ "name": "soft_break_spaces_hang_on_the_previous_row", "kind": "function" }, + { + "path": "bottom_pane/textarea/wrapping_tests.rs", + "name": "vertical_navigation_clamps_saved_column_after_resize", + "kind": "function" + }, + { + "path": "bottom_pane/textarea/wrapping_tests.rs", + "name": "vertical_navigation_preserves_destination_tab_columns", + "kind": "function" + }, + { + "path": "bottom_pane/textarea/wrapping_tests.rs", + "name": "vertical_navigation_preserves_preferred_column_across_short_wrapped_rows", + "kind": "function" + }, { "path": "bottom_pane/textarea/wrapping_tests.rs", "name": "wrapped_lines_keep_utf8_boundaries_and_reserve_cursor_rows", @@ -71791,66 +72800,6 @@ "name": "to_mode_uses_mask_values_and_fallbacks", "kind": "function" }, - { - "path": "command_popup.rs", - "name": "CommandPopup", - "kind": "type" - }, - { - "path": "command_popup.rs", - "name": "CommandPopupAction", - "kind": "type" - }, - { - "path": "command_popup.rs", - "name": "commands", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "enter_executes_immediate_commands_and_completes_argument_commands", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "filters_by_prefix_and_wraps_selection", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "for_composer", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "handle_event", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "matches", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "render", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "selected", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "test_backend_renders_commands_and_localized_descriptions", - "kind": "function" - }, - { - "path": "command_popup.rs", - "name": "update", - "kind": "function" - }, { "path": "cwd_prompt.rs", "name": "cwd_prompt_can_select_current", @@ -72221,6 +73170,11 @@ "name": "entry", "kind": "function" }, + { + "path": "entry.rs", + "name": "entry_status_style", + "kind": "function" + }, { "path": "entry.rs", "name": "format_line", @@ -72246,6 +73200,11 @@ "name": "item_kinds_have_distinct_terminal_layouts", "kind": "function" }, + { + "path": "entry.rs", + "name": "lifecycle_status_suffixes_use_the_shared_semantic_tones", + "kind": "function" + }, { "path": "entry.rs", "name": "lines", @@ -72281,11 +73240,6 @@ "name": "user_image_summaries_use_numbered_labels_in_all_product_locales", "kind": "function" }, - { - "path": "entry.rs", - "name": "with_status_suffix", - "kind": "function" - }, { "path": "exec_cell/live_output.rs", "name": "lines", @@ -72686,6 +73640,11 @@ "name": "status_text", "kind": "function" }, + { + "path": "history_cell/mcp.rs", + "name": "compact_inventory_matches_codex_summary_shape", + "kind": "function" + }, { "path": "history_cell/mcp.rs", "name": "display_hyperlink_lines", @@ -72701,6 +73660,46 @@ "name": "display_lines", "kind": "function" }, + { + "path": "history_cell/mcp.rs", + "name": "full_inventory_preserves_resource_order_from_app_server", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "full_inventory_uses_codex_empty_tool_label", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "inventory_empty_state_is_localized", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "inventory_is_sorted_and_full_detail_includes_tool_counts_and_names", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "line_text", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "mcp_auth_status_label", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "mcp_connection_status_label", + "kind": "function" + }, + { + "path": "history_cell/mcp.rs", + "name": "mcp_inventory_lines", + "kind": "function" + }, { "path": "history_cell/mcp.rs", "name": "McpInventoryLoadingCell", @@ -72731,6 +73730,11 @@ "name": "raw_lines", "kind": "function" }, + { + "path": "history_cell/mcp.rs", + "name": "status", + "kind": "function" + }, { "path": "history_cell/mcp_result.rs", "name": "compact_json", @@ -73146,6 +74150,21 @@ "name": "worked_label", "kind": "function" }, + { + "path": "history_cell/session.rs", + "name": "card_inner_width", + "kind": "function" + }, + { + "path": "history_cell/session.rs", + "name": "cell", + "kind": "function" + }, + { + "path": "history_cell/session.rs", + "name": "dangerous_permissions_are_visible_without_becoming_a_fixed_header", + "kind": "function" + }, { "path": "history_cell/session.rs", "name": "display_lines", @@ -73156,6 +74175,11 @@ "name": "display_lines", "kind": "function" }, + { + "path": "history_cell/session.rs", + "name": "format_directory", + "kind": "function" + }, { "path": "history_cell/session.rs", "name": "new", @@ -73166,6 +74190,11 @@ "name": "new", "kind": "function" }, + { + "path": "history_cell/session.rs", + "name": "padded_label", + "kind": "function" + }, { "path": "history_cell/session.rs", "name": "raw_lines", @@ -73176,6 +74205,16 @@ "name": "raw_lines", "kind": "function" }, + { + "path": "history_cell/session.rs", + "name": "session_header_is_a_bounded_codex_shaped_box", + "kind": "function" + }, + { + "path": "history_cell/session.rs", + "name": "session_header_labels_cover_all_product_locales", + "kind": "function" + }, { "path": "history_cell/session.rs", "name": "SessionHeaderHistoryCell", @@ -73186,6 +74225,11 @@ "name": "SessionInfoCell", "kind": "type" }, + { + "path": "history_cell/session.rs", + "name": "with_border", + "kind": "function" + }, { "path": "history_filter.rs", "name": "canonical_ids_filter_cross_page_review_messages_without_text_matching", @@ -73756,6 +74800,11 @@ "name": "agents_overview_title", "kind": "function" }, + { + "path": "locale.rs", + "name": "approval_controls", + "kind": "function" + }, { "path": "locale.rs", "name": "approval_option", @@ -73766,6 +74815,21 @@ "name": "approval_title", "kind": "function" }, + { + "path": "locale.rs", + "name": "auto_resolution_countdown", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "auto_resolution_countdown_covers_all_product_locales", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "change_model_hint", + "kind": "function" + }, { "path": "locale.rs", "name": "collaboration_mode_statuses_cover_all_product_locales", @@ -73776,6 +74840,11 @@ "name": "command_output_omission_markers_cover_all_product_locales", "kind": "function" }, + { + "path": "locale.rs", + "name": "composer_placeholder", + "kind": "function" + }, { "path": "locale.rs", "name": "created_label", @@ -73796,6 +74865,11 @@ "name": "detail", "kind": "function" }, + { + "path": "locale.rs", + "name": "draft_ready_hint", + "kind": "function" + }, { "path": "locale.rs", "name": "edit_queued_input_hint", @@ -73891,6 +74965,11 @@ "name": "image_label", "kind": "function" }, + { + "path": "locale.rs", + "name": "interactive_overlay_controls_cover_all_product_locales", + "kind": "function" + }, { "path": "locale.rs", "name": "interrupt_hint", @@ -73906,6 +74985,16 @@ "name": "mcp_approval_labels_cover_all_product_locales", "kind": "function" }, + { + "path": "locale.rs", + "name": "mcp_auth_label", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_auth_status", + "kind": "function" + }, { "path": "locale.rs", "name": "mcp_elicitation_approval_option", @@ -73946,6 +75035,66 @@ "name": "mcp_elicitation_title", "kind": "function" }, + { + "path": "locale.rs", + "name": "mcp_inventory_labels_cover_all_product_locales", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_inventory_title", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_no_servers", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_no_tools_available", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_none", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_resource_templates_label", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_resources_label", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_status", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_tool_unit", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_tools_label", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_usage", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "mcp_use_verbose", + "kind": "function" + }, { "path": "locale.rs", "name": "model_label", @@ -74036,6 +75185,16 @@ "name": "pwd_usage", "kind": "function" }, + { + "path": "locale.rs", + "name": "queue_message_hint", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "queue_short_hint", + "kind": "function" + }, { "path": "locale.rs", "name": "queue_unavailable_status_covers_all_product_locales", @@ -74046,6 +75205,31 @@ "name": "ready_label", "kind": "function" }, + { + "path": "locale.rs", + "name": "request_cancel_hint", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "request_notes_hint", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "request_question_nav_hint", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "request_select_hint", + "kind": "function" + }, + { + "path": "locale.rs", + "name": "request_submit_hint", + "kind": "function" + }, { "path": "locale.rs", "name": "resolve", @@ -74141,6 +75325,11 @@ "name": "resume_transcript_loading", "kind": "function" }, + { + "path": "locale.rs", + "name": "shortcuts_hint", + "kind": "function" + }, { "path": "locale.rs", "name": "skill_load_error_message", @@ -74786,6 +75975,11 @@ "name": "handle_event", "kind": "function" }, + { + "path": "model_picker.rs", + "name": "long_model_catalog_keeps_selected_row_inside_bounded_popup", + "kind": "function" + }, { "path": "model_picker.rs", "name": "model", @@ -74821,6 +76015,11 @@ "name": "picker_filters_hidden_models_and_selects_default_first", "kind": "function" }, + { + "path": "model_picker.rs", + "name": "picker_navigation_wraps_and_accepts_control_bindings", + "kind": "function" + }, { "path": "model_picker.rs", "name": "query", @@ -74836,11 +76035,21 @@ "name": "render_with_locale", "kind": "function" }, + { + "path": "model_picker.rs", + "name": "searchable_picker_keeps_plain_vim_letters_for_query_input", + "kind": "function" + }, { "path": "model_picker.rs", "name": "selected_model", "kind": "function" }, + { + "path": "model_picker.rs", + "name": "unhandled_control_keys_do_not_pollute_model_query", + "kind": "function" + }, { "path": "model_picker.rs", "name": "visible_indices", @@ -75026,11 +76235,26 @@ "name": "render", "kind": "function" }, + { + "path": "pager_overlay.rs", + "name": "reset_transcript_anchor_at_top", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "resize_recomputes_scroll_bounds_and_tiny_areas_do_not_panic", "kind": "function" }, + { + "path": "pager_overlay.rs", + "name": "set_older_history_available", + "kind": "function" + }, + { + "path": "pager_overlay.rs", + "name": "static_overlay_never_requests_older_history", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "static_overlay_wraps_scrolls_jumps_and_closes", @@ -75071,81 +76295,41 @@ "name": "transcript_overlay_preserves_manual_scroll_when_projection_grows", "kind": "function" }, + { + "path": "pager_overlay.rs", + "name": "transcript_overlay_remaps_manual_anchor_when_streaming_line_grows_in_place", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "transcript_overlay_remaps_manual_anchor_when_width_reflows", "kind": "function" }, + { + "path": "pager_overlay.rs", + "name": "transcript_overlay_requests_older_history_when_scrolled_to_the_top", + "kind": "function" + }, + { + "path": "pager_overlay.rs", + "name": "transcript_overlay_reset_anchor_keeps_newly_loaded_beginning_visible", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "transcript_overlay_starts_at_tail_follows_updates_and_closes_with_ctrl_t", "kind": "function" }, + { + "path": "pager_overlay.rs", + "name": "transcript_overlay_without_older_history_consumes_top_navigation", + "kind": "function" + }, { "path": "pager_overlay.rs", "name": "wrapped_line_starts", "kind": "function" }, - { - "path": "pending_input_preview.rs", - "name": "can_restore_submission", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "desired_height", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "edit_hint_requires_a_lossless_composer_projection", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "line_text", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "narrow_preview_wraps_and_caps_each_submission", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "preview_lines", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "preview_uses_canonical_multimodal_queue_and_caps_visible_items", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "queue_header_and_image_label_cover_all_product_locales", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "render", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "submission", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "submission_preview_lines", - "kind": "function" - }, - { - "path": "pending_input_preview.rs", - "name": "submission_summary", - "kind": "function" - }, { "path": "projection.rs", "name": "active_turn_id", @@ -75171,6 +76355,11 @@ "name": "append_delta", "kind": "function" }, + { + "path": "projection.rs", + "name": "append_reasoning_section_break", + "kind": "function" + }, { "path": "projection.rs", "name": "apply", @@ -75258,7 +76447,7 @@ }, { "path": "projection.rs", - "name": "hook_status", + "name": "hook_status_message", "kind": "function" }, { @@ -75271,6 +76460,11 @@ "name": "image_generation_status", "kind": "function" }, + { + "path": "projection.rs", + "name": "latest_summary_line", + "kind": "function" + }, { "path": "projection.rs", "name": "mcp_content_previews", @@ -75336,6 +76530,11 @@ "name": "record_assistant_phase", "kind": "function" }, + { + "path": "projection.rs", + "name": "remember_reasoning_status", + "kind": "function" + }, { "path": "projection.rs", "name": "remove_hidden_entries", @@ -75401,6 +76600,11 @@ "name": "WebSearchLifecycle", "kind": "type" }, + { + "path": "projection_tests.rs", + "name": "a_new_turn_can_create_a_streaming_item_after_a_terminal_turn", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "agent_message", @@ -75466,6 +76670,11 @@ "name": "dynamic_text_previews_are_compact_bounded_and_ignore_media_urls", "kind": "function" }, + { + "path": "projection_tests.rs", + "name": "explicit_status_clears_reasoning_summary_header", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "final_answer_phase_wins_over_older_commentary", @@ -75536,6 +76745,16 @@ "name": "item_result_fields_become_structured_display_summaries", "kind": "function" }, + { + "path": "projection_tests.rs", + "name": "late_agent_delta_does_not_reopen_completed_item", + "kind": "function" + }, + { + "path": "projection_tests.rs", + "name": "late_reasoning_section_boundary_does_not_mutate_completed_history", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "legacy_agent_messages_without_phase_remain_final_answers", @@ -75571,6 +76790,16 @@ "name": "realtime_review_boundary_hides_only_messages_inside_interval", "kind": "function" }, + { + "path": "projection_tests.rs", + "name": "reasoning_summary_parts_keep_streamed_section_boundaries", + "kind": "function" + }, + { + "path": "projection_tests.rs", + "name": "reasoning_summary_updates_running_status_with_latest_usable_line", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "review_boundary", @@ -75586,6 +76815,21 @@ "name": "streamed_agent_message_phase_is_repaired_by_item_completion", "kind": "function" }, + { + "path": "projection_tests.rs", + "name": "terminal_turn_closes_unrepaired_stream_tail_against_late_delta", + "kind": "function" + }, + { + "path": "projection_tests.rs", + "name": "terminal_turn_rejects_late_delta_for_unknown_item", + "kind": "function" + }, + { + "path": "projection_tests.rs", + "name": "terminal_turn_rejects_late_item_started_but_accepts_canonical_completion", + "kind": "function" + }, { "path": "projection_tests.rs", "name": "test_thread", @@ -76371,6 +77615,11 @@ "name": "load_transcript_preview_with_handle", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "long_resume_list_keeps_selected_session_visible_after_end_navigation", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "mark_preview_loading", @@ -76401,6 +77650,11 @@ "name": "page_navigation_matches_codex_shape_and_reaches_the_next_cursor", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "picker_control_navigation_matches_codex_list_bindings", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "picker_controls_match_codex_resume_shortcut_shapes", @@ -76426,6 +77680,11 @@ "name": "picker_search_filters_preview_name_path_and_thread_id", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "picker_vertical_constraints", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "PickerAction", @@ -76476,11 +77735,41 @@ "name": "render_expanded_session_details", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "render_picker_footer", + "kind": "function" + }, + { + "path": "resume_picker.rs", + "name": "render_session_list", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "render_with_locale", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "resume_picker_render_matrix_stays_bounded_across_locales_and_widths", + "kind": "function" + }, + { + "path": "resume_picker.rs", + "name": "resume_picker_short_terminal_heights_remain_renderable", + "kind": "function" + }, + { + "path": "resume_picker.rs", + "name": "resume_search_line", + "kind": "function" + }, + { + "path": "resume_picker.rs", + "name": "resume_search_line_preserves_toolbar_and_display_width_on_narrow_terminals", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "run_fork_picker_with_app_server", @@ -76501,6 +77790,11 @@ "name": "run_session_picker_with_app_server", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "searchable_picker_keeps_plain_vim_letters_for_query_input", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "selected_thread_id", @@ -76511,6 +77805,11 @@ "name": "selection", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "session_list_item", + "kind": "function" + }, { "path": "resume_picker.rs", "name": "session_picker_action_keeps_resume_and_fork_selection_distinct", @@ -76696,6 +77995,11 @@ "name": "truncation_preserves_display_width", "kind": "function" }, + { + "path": "resume_picker.rs", + "name": "unhandled_control_keys_do_not_pollute_resume_query", + "kind": "function" + }, { "path": "resume_picker/archive.rs", "name": "archive_shortcut_available", @@ -77081,6 +78385,11 @@ "name": "visible_terminal_text_ignores_cursor_controls", "kind": "function" }, + { + "path": "runtime_pty_tests.rs", + "name": "wait_for_any_marker", + "kind": "function" + }, { "path": "runtime_pty_tests.rs", "name": "wait_for_ledger_kind_and_scenario", @@ -77246,16 +78555,296 @@ "name": "working_status_is_localized_and_width_bounded", "kind": "function" }, + { + "path": "status_indicator_widget.rs", + "name": "active_status_height_and_width_are_bounded_for_supported_locales", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "desired_height", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "desired_height_with_messages", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "details_are_wrapped_and_bounded_by_max_lines", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "details_args_can_disable_capitalization_and_limit_lines", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "details_overflow_adds_ellipsis", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "elapsed_time_matches_codex_compact_format", + "kind": "function" + }, { "path": "status_indicator_widget.rs", "name": "fmt_elapsed_compact", "kind": "function" }, + { + "path": "status_indicator_widget.rs", + "name": "hook_and_details_reflow_to_distinct_rows_without_dropping_the_header", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "hook_status_moves_to_a_second_line_when_the_header_does_not_fit", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "hook_status_reflows_without_displacing_controls_or_details", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "lines", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "localized_status_keeps_interrupt_hint_and_width_bound", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "new", + "kind": "function" + }, { "path": "status_indicator_widget.rs", "name": "render", "kind": "function" }, + { + "path": "status_indicator_widget.rs", + "name": "render", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "render_with_messages", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "renders_remapped_interrupt_hint", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "renders_without_spinner_when_animations_disabled", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "set_interrupt_hint", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "set_interrupt_hint_visible", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "status_line", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "StatusDetailsCapitalization", + "kind": "type" + }, + { + "path": "status_indicator_widget.rs", + "name": "StatusIndicatorWidget", + "kind": "type" + }, + { + "path": "status_indicator_widget.rs", + "name": "update_details", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "update_header", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "update_hook_status_message", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "update_inline_message", + "kind": "function" + }, + { + "path": "status_indicator_widget.rs", + "name": "wrapped_details_lines", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "default", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "elapsed_at", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "pause_at", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "reset", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "resume_at", + "kind": "function" + }, + { + "path": "status_indicator_widget/timer.rs", + "name": "StatusTimer", + "kind": "type" + }, + { + "path": "status_indicator_widget/timer_tests.rs", + "name": "pauses_and_resumes_without_counting_wait_time", + "kind": "function" + }, + { + "path": "style.rs", + "name": "accent_is_palette_aware_and_has_a_no_color_fallback", + "kind": "function" + }, + { + "path": "style.rs", + "name": "accent_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "accent_style_for", + "kind": "function" + }, + { + "path": "style.rs", + "name": "attention_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "blend", + "kind": "function" + }, + { + "path": "style.rs", + "name": "failure_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "footer_hint_label_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "footer_hint_label_style_for", + "kind": "function" + }, + { + "path": "style.rs", + "name": "footer_styles_keep_labels_quiet_on_all_palettes", + "kind": "function" + }, + { + "path": "style.rs", + "name": "is_light", + "kind": "function" + }, + { + "path": "style.rs", + "name": "muted_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "status_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "status_style_for", + "kind": "function" + }, + { + "path": "style.rs", + "name": "status_tones_preserve_light_dark_and_no_color_semantics", + "kind": "function" + }, + { + "path": "style.rs", + "name": "StatusTone", + "kind": "type" + }, + { + "path": "style.rs", + "name": "table_separator_blends_on_truecolor_and_dims_without_palette_support", + "kind": "function" + }, + { + "path": "style.rs", + "name": "table_separator_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "table_separator_style_for", + "kind": "function" + }, + { + "path": "style.rs", + "name": "user_message_bg_rgb", + "kind": "function" + }, + { + "path": "style.rs", + "name": "user_message_style", + "kind": "function" + }, + { + "path": "style.rs", + "name": "user_message_style_for", + "kind": "function" + }, + { + "path": "style.rs", + "name": "user_message_surface_adapts_to_dark_light_and_no_color", + "kind": "function" + }, { "path": "table_detect.rs", "name": "advance", @@ -77676,6 +79265,11 @@ "name": "nearest_ansi256_color_is_indexed", "kind": "function" }, + { + "path": "terminal_palette.rs", + "name": "no_color_disables_semantic_colors_without_mutating_process_env", + "kind": "function" + }, { "path": "terminal_palette.rs", "name": "rgb_color", @@ -77706,6 +79300,11 @@ "name": "stdout_color_level", "kind": "function" }, + { + "path": "terminal_palette.rs", + "name": "stdout_color_level_for_env", + "kind": "function" + }, { "path": "terminal_palette.rs", "name": "StdoutColorLevel", @@ -78511,6 +80110,11 @@ "name": "clear", "kind": "function" }, + { + "path": "transcript_reflow.rs", + "name": "clear", + "kind": "function" + }, { "path": "transcript_reflow.rs", "name": "clear_pending_reflow", @@ -78566,6 +80170,16 @@ "name": "reflow_needed_for_width", "kind": "function" }, + { + "path": "transcript_reflow.rs", + "name": "remap_transcript_offset", + "kind": "function" + }, + { + "path": "transcript_reflow.rs", + "name": "resolve", + "kind": "function" + }, { "path": "transcript_reflow.rs", "name": "schedule_debounced", @@ -78601,16 +80215,41 @@ "name": "TranscriptReflowState", "kind": "type" }, + { + "path": "transcript_reflow.rs", + "name": "TranscriptViewport", + "kind": "type" + }, { "path": "transcript_reflow.rs", "name": "TranscriptWidthChange", "kind": "type" }, + { + "path": "transcript_reflow.rs", + "name": "viewport_keeps_pinned_bottom_following_new_content", + "kind": "function" + }, + { + "path": "transcript_reflow.rs", + "name": "viewport_preserves_manual_anchor_when_stream_tail_grows", + "kind": "function" + }, + { + "path": "transcript_reflow.rs", + "name": "viewport_remaps_manual_anchor_when_width_reflows", + "kind": "function" + }, { "path": "transcript_reflow.rs", "name": "visible_history_rows", "kind": "function" }, + { + "path": "transcript_reflow.rs", + "name": "wrapped_line_starts", + "kind": "function" + }, { "path": "tui.rs", "name": "drop", @@ -79076,6 +80715,11 @@ "name": "test_schedule_frame_in_triggers_at_delay", "kind": "function" }, + { + "path": "view.rs", + "name": "active_status_message", + "kind": "function" + }, { "path": "view.rs", "name": "active_turn_status_does_not_overflow_a_tiny_terminal", @@ -79091,6 +80735,11 @@ "name": "approval_hides_an_open_slash_command_popup", "kind": "function" }, + { + "path": "view.rs", + "name": "approval_narrow_layout_keeps_primary_controls_visible", + "kind": "function" + }, { "path": "view.rs", "name": "approval_replaces_the_composer_with_actionable_options", @@ -79131,6 +80780,16 @@ "name": "history_search_preview_highlights_matches_until_accepted", "kind": "function" }, + { + "path": "view.rs", + "name": "idle_composer_uses_codex_prompt_and_localized_placeholder", + "kind": "function" + }, + { + "path": "view.rs", + "name": "interactive_overlays_remain_actionable_across_supported_widths_and_locales", + "kind": "function" + }, { "path": "view.rs", "name": "narrow_terminal_does_not_overflow_or_panic", @@ -79148,12 +80807,17 @@ }, { "path": "view.rs", - "name": "render_header", + "name": "render_transcript", "kind": "function" }, { "path": "view.rs", - "name": "render_transcript", + "name": "render_transient_status", + "kind": "function" + }, + { + "path": "view.rs", + "name": "request_user_input_narrow_layout_keeps_submit_and_cancel_visible", "kind": "function" }, { @@ -79161,6 +80825,11 @@ "name": "screen_chunks", "kind": "function" }, + { + "path": "view.rs", + "name": "ScreenChunks", + "kind": "type" + }, { "path": "view.rs", "name": "secret_user_input_is_masked_in_the_test_backend", @@ -79168,12 +80837,12 @@ }, { "path": "view.rs", - "name": "status_pager_owns_the_frame_and_renders_current_session_facts", + "name": "status_and_footer_geometry_remains_stable_across_supported_widths_and_locales", "kind": "function" }, { "path": "view.rs", - "name": "status_text", + "name": "status_pager_owns_the_frame_and_renders_current_session_facts", "kind": "function" }, { @@ -79251,6 +80920,16 @@ "name": "transcript_scroll_offset", "kind": "function" }, + { + "path": "view.rs", + "name": "transient_status", + "kind": "function" + }, + { + "path": "view.rs", + "name": "transient_status_keeps_action_feedback_visible_without_idle_status_bar", + "kind": "function" + }, { "path": "view.rs", "name": "user_visible_header_labels_cover_all_product_locales", @@ -80012,7 +81691,7 @@ "kind": "function" } ], - "treeSha256": "564f245d50c64fb738f01c27765d6be15f5d386d35cc004b27c40a8fe6ae3be1" + "treeSha256": "4c313cab1ca8a1d6328ac6bb386ff3f95aa8c2d96a9caa841725225e7b315beb" }, "codex-rs/tui/tests": { "root": "/Users/coso/Documents/dev/rust/codex/codex-rs/tui/tests", @@ -80309,11 +81988,12 @@ }, "lime-rs/crates/tui/tests": { "root": "/Users/coso/Documents/dev/ai/aiclientproxy/lime/lime-rs/crates/tui/tests", - "fileCount": 10, + "fileCount": 11, "files": [ "all.rs", "manager_dependency_regression.rs", "suite/focus_palette.rs", + "suite/history_pagination.rs", "suite/mod.rs", "suite/reconnect.rs", "suite/resize_reflow.rs", @@ -80322,7 +82002,7 @@ "suite/vt100_live_commit.rs", "test_backend.rs" ], - "symbolCount": 82, + "symbolCount": 88, "symbols": [ { "path": "manager_dependency_regression.rs", @@ -80409,6 +82089,21 @@ "name": "start", "kind": "function" }, + { + "path": "suite/focus_palette.rs", + "name": "start_resume", + "kind": "function" + }, + { + "path": "suite/focus_palette.rs", + "name": "start_with_mode", + "kind": "function" + }, + { + "path": "suite/focus_palette.rs", + "name": "StartMode", + "kind": "type" + }, { "path": "suite/focus_palette.rs", "name": "wait_for_exit", @@ -80434,11 +82129,26 @@ "name": "wait_for_startup", "kind": "function" }, + { + "path": "suite/focus_palette.rs", + "name": "wait_for_startup_with_timeout", + "kind": "function" + }, { "path": "suite/focus_palette.rs", "name": "write_input", "kind": "function" }, + { + "path": "suite/history_pagination.rs", + "name": "older_pagination_reconciles_review_prompts_across_page_boundaries", + "kind": "function" + }, + { + "path": "suite/history_pagination.rs", + "name": "required_path", + "kind": "function" + }, { "path": "suite/reconnect.rs", "name": "answer_startup_queries", @@ -80735,7 +82445,7 @@ "kind": "function" } ], - "treeSha256": "ae26e9011f039225212007f71d5d2e9b8a84478162a2d9257907719e1fb874b7" + "treeSha256": "f26ccf24850b1c80874c7d3a03b913a1a82d10db44d72cf1708fada69ae92a8e" } }, "comparisons": { @@ -80883,10 +82593,7 @@ "bottom_pane/async_questions/state.rs", "bottom_pane/async_questions/state_tests.rs", "bottom_pane/bottom_pane_view.rs", - "bottom_pane/chat_composer/agents_navigation.rs", "bottom_pane/chat_composer/agents_navigation_tests.rs", - "bottom_pane/chat_composer/completion_target.rs", - "bottom_pane/chat_composer/completion_target_tests.rs", "bottom_pane/chat_composer/composer_layout.rs", "bottom_pane/chat_composer/embedded_input_tests.rs", "bottom_pane/chat_composer/inline_input.rs", @@ -80896,7 +82603,6 @@ "bottom_pane/chat_composer_history.rs", "bottom_pane/chat_composer_history/search_batch.rs", "bottom_pane/chat_composer_history/search_batch_tests.rs", - "bottom_pane/command_popup.rs", "bottom_pane/custom_prompt_view.rs", "bottom_pane/custom_prompt_view_tests.rs", "bottom_pane/effort_ignition.rs", @@ -80924,7 +82630,6 @@ "bottom_pane/mentions_v2/search_mode.rs", "bottom_pane/multi_select_picker.rs", "bottom_pane/paste_burst.rs", - "bottom_pane/pending_input_preview.rs", "bottom_pane/pending_thread_approvals.rs", "bottom_pane/popup_consts.rs", "bottom_pane/prompt_args.rs", @@ -80933,7 +82638,6 @@ "bottom_pane/request_user_input/layout.rs", "bottom_pane/scroll_state.rs", "bottom_pane/selection_popup_common.rs", - "bottom_pane/selection_row_layout.rs", "bottom_pane/selection_tabs.rs", "bottom_pane/skill_popup.rs", "bottom_pane/skills_toggle_view.rs", @@ -81245,8 +82949,6 @@ "status/tests.rs", "status/thread_usage.rs", "status/thread_usage_tests.rs", - "status_indicator_widget/timer.rs", - "status_indicator_widget/timer_tests.rs", "streaming/chunking.rs", "streaming/code_fence.rs", "streaming/code_fence_render_tests.rs", @@ -81257,7 +82959,6 @@ "streaming/render.rs", "streaming/render_tests.rs", "streaming/table_holdback.rs", - "style.rs", "summary_shimmer.rs", "summary_shimmer_tests.rs", "system_motion.rs", @@ -81309,6 +83010,11 @@ "worktree_startup_tests.rs" ], "filesOnlyInLime": [ + "app/input_flow.rs", + "app/input_submission.rs", + "app/interrupts.rs", + "app/tool_lifecycle.rs", + "app/turn_lifecycle.rs", "bottom_pane/chat_composer/file_search_popup.rs", "bottom_pane/chat_composer/skill_popup.rs", "bottom_pane/render.rs", @@ -81401,7 +83107,6 @@ "AsyncQuestions", "AttachedImage", "AuthModeWidget", - "AutoResolutionTiming", "AutoReviewMode", "AutomaticModelSwitch", "AutomaticModelSwitchReason", @@ -81508,7 +83213,6 @@ "DisconnectInfo", "DismissibleView", "DisplayCondition", - "DollarQueryKind", "DrainPlan", "DrawCommand", "DynamicToolMcpHandler", @@ -81949,7 +83653,6 @@ "SelectedThreadUsage", "Selection", "SelectionAction", - "SelectionDescriptionLayout", "SelectionItem", "SelectionRowDisplay", "SelectionTab", @@ -82014,12 +83717,10 @@ "StatusAccountDisplay", "StatusContextWindowData", "StatusCopySource", - "StatusDetailsCapitalization", "StatusHistoryCell", "StatusHistoryHandle", "StatusIndicator", "StatusIndicatorState", - "StatusIndicatorWidget", "StatusLineAccent", "StatusLineGitSummary", "StatusLineItem", @@ -82034,9 +83735,7 @@ "StatusSurfacePreviewItem", "StatusSurfaceSelections", "StatusThreadUsage", - "StatusTimer", "StatusTokenUsageData", - "StatusTone", "StderrState", "SteerSwitch", "Step", @@ -82049,8 +83748,6 @@ "StreamingRender", "StyledMarkerRenderable", "SubmissionValidation", - "SummaryHintKind", - "SummaryLeft", "SuspendContext", "TableBodyRow", "TableCell", @@ -82058,7 +83755,6 @@ "TableColumnMetrics", "TableHoldbackScanner", "TableHoldbackState", - "Target", "TargetOs", "TaskMention", "TemporaryStructuredThreadOptions", @@ -82129,7 +83825,6 @@ "TuiSessionReporter", "TuiSharedCliOptions", "TurnAbortReason", - "TurnLifecycleState", "TurnPermissionsOverride", "UnansweredConfirmationData", "UnansweredConfirmationLayout", @@ -82212,8 +83907,6 @@ "accent_color_for", "accent_fallback", "accent_rgb", - "accent_style", - "accent_style_for", "accent_style_uses_cyan_on_dark_or_unknown_backgrounds", "accent_style_uses_darker_cyan_on_light_backgrounds", "accept", @@ -82419,7 +84112,6 @@ "agent_turn_complete_does_not_override_pending_plan_mode_prompt_notification", "agent_turn_complete_notification_does_not_reuse_stale_copy_source", "agent_turn_preview", - "agents_navigation_available", "agents_navigation_hint_snapshots", "agents_navigation_requires_local_daemon", "agents_overview_acknowledges_inactive_steer_before_interrupt", @@ -82799,7 +84491,6 @@ "auto_resolution_next_frame_delay_at", "auto_resolution_paste_interaction_snoozes_timer", "auto_resolution_resets_for_queued_request", - "auto_resolution_timing_at", "auto_resolution_uses_is_blocking_without_auto_resolution_ms", "auto_resolution_visible_countdown_is_red", "auto_resolution_visible_countdown_snapshot", @@ -82932,7 +84623,6 @@ "bindings", "bindings_for", "bindings_for_action", - "blend", "blended_total", "blocked_and_failed_hooks_render_feedback_and_errors", "blocked_follow_up_keeps_unanswered_questions", @@ -82970,7 +84660,6 @@ "branch_diff_stats_to_default_branch", "breakable_unicode_space_hangs_before_following_text", "btw_hidden_in_empty_filter_but_shown_for_prefix", - "budget_limited_turn_ids_are_consumed", "budget_limited_turn_restores_queued_input_without_submitting", "buffer_hyperlinks_follow_word_wrapping", "buffer_hyperlinks_follow_wrapped_halfwidth_dakuten", @@ -82999,7 +84688,6 @@ "build_feedback_upload_params", "build_feedback_upload_params_includes_thread_id_and_rollout_path", "build_feedback_upload_params_omits_rollout_path_without_logs", - "build_full_line", "build_header", "build_keymap_action_menu_params", "build_keymap_capture_view", @@ -83016,7 +84704,6 @@ "build_line", "build_memory_settings_edits", "build_model_selection_edits", - "build_name_spans", "build_options", "build_oss_provider_edit", "build_pet_picker_params", @@ -83073,7 +84760,6 @@ "can_reuse_implicit_local_daemon", "can_reuse_implicit_local_daemon_requires_default_launch_config", "can_run_ctrl_l_clear_now", - "can_show_left_with_context", "can_toggle_fast_mode_from_keybinding", "cancel_active_attempt", "cancel_active_attempt_notifies_device_code_login", @@ -83112,13 +84798,11 @@ "capture_view", "capture_view_snapshot", "captures_repeated_strokes_but_ignores_auto_repeat", - "card_inner_width", "card_lines", "case_insensitive_match_ranges", "catch_up_batch_drains_current_backlog", "caught_panic_pty_child", "caught_panic_rehides_cursor_before_repainting_and_restores_its_shape", - "cell", "cell_date", "cell_display_width", "cell_to_text", @@ -83202,7 +84886,6 @@ "clear_managed_terminal_title", "clear_mcp_inventory_loading", "clear_misalignment_for_new_turn", - "clear_notes_and_focus_options", "clear_notes_draft", "clear_only_ui_reset_allows_active_skill_warning_to_render_again", "clear_only_ui_reset_preserves_chat_session_state", @@ -83325,7 +85008,6 @@ "column_classification_token_heavy_for_local_path_lists", "column_widths_bulk_shrink_large_token_heavy_column", "combined_billing_surfaces_wait_for_credits_and_cost", - "combined_description", "combined_preview_snapshot", "comfortable_zebra_lines_use_full_width_background", "command", @@ -83768,16 +85450,10 @@ "current_permission_profile", "current_plan_type", "current_prefixed_token", - "current_prefixed_token_affinity_does_not_cross_line_break", - "current_prefixed_token_affinity_does_not_cross_separator_before_plain_text", "current_prefixed_token_bounds_right_candidate_before_bound_element", "current_prefixed_token_falls_back_from_bound_at_token_on_right", "current_prefixed_token_falls_back_from_bound_dollar_suffix_on_right", - "current_prefixed_token_keeps_nested_dollar_prefix_in_same_token", "current_prefixed_token_prefers_right_after_adjacent_target", - "current_prefixed_token_prefers_right_at_token_start", - "current_prefixed_token_range", - "current_prefixed_token_range_with_dollar_predicate", "current_prefixed_token_stops_at_trailing_space_before_line_break", "current_prefixed_token_stops_before_overlapping_element", "current_prefixed_token_targets_later_segment_between_bound_elements", @@ -84022,8 +85698,6 @@ "detail_wrapped_lines", "detailed_reset_credits", "details", - "details_args_can_disable_capitalization_and_limit_lines", - "details_overflow_adds_ellipsis", "detect", "detect_backend", "detect_keyboard_enhancement_supported", @@ -84137,9 +85811,6 @@ "does_not_suppress_printable_quit_key_when_api_key_input_is_empty", "does_not_suppress_when_not_in_api_key_entry", "does_not_wrap_code_blocks", - "dollar_query_classifies_shell_and_skill_syntax", - "dollar_query_is_completable", - "dollar_query_kind", "done_only_caption_cannot_retire_a_newer_voice_answer", "dot_repeat_has_visual_snapshot_coverage", "dot_repeat_replays_character_find_and_buffer_jump_operators", @@ -84240,8 +85911,6 @@ "effort_transition_does_not_queue_a_missing_outgoing_status_line", "effort_transition_keeps_the_full_footer_row", "effort_transition_never_replaces_a_footer_flash", - "elapsed", - "elapsed_at", "elapsed_since_start", "element_id_for_exact_range", "element_payloads", @@ -84902,11 +86571,9 @@ "footer_flash_overrides_footer_hint_override", "footer_from_props_lines", "footer_height", - "footer_hint", "footer_hint_items_line", "footer_hint_items_width", "footer_hint_key_style", - "footer_hint_label_style", "footer_hint_line", "footer_hint_lines", "footer_hint_row_is_separated_from_composer", @@ -84969,14 +86636,12 @@ "form_request", "format_action_binding_summary", "format_additional_permissions_rule", - "format_auto_resolution_remaining", "format_config_error", "format_config_error_preserves_server_validation_message", "format_counted_details", "format_credit_amount", "format_credit_balance", "format_credit_micros", - "format_directory", "format_directory_display", "format_directory_display_truncates_halfwidth_sound_marks", "format_directory_inner", @@ -85499,7 +87164,6 @@ "hook_started_run", "hook_status_and_history_snapshot", "hook_status_frame", - "hook_status_reflows_without_displacing_controls_or_details", "hook_title", "hook_trust_label", "hooks_list_entry_for_cwd", @@ -85726,7 +87390,6 @@ "is_cancellable_work_active", "is_chatgpt_login_allowed", "is_close_fence", - "is_common_env_var", "is_ctrl_exit_combo", "is_current_field_answered", "is_current_misalignment_review", @@ -85760,9 +87423,7 @@ "is_inside_element", "is_iterm2_terminal", "is_kitty_protocol", - "is_light", "is_markdown_fence_info_basic", - "is_mention_name_char", "is_mention_name_char_char", "is_name_byte", "is_navigating", @@ -85942,7 +87603,6 @@ "latest_session_lookup_params_keep_local_filters_for_embedded_sessions", "latest_session_lookup_params_keep_local_filters_for_local_daemon_sessions", "latest_session_lookup_params_omit_local_filters_for_remote_sessions", - "latest_summary_line", "latest_tag_without_prefix_is_invalid", "latest_thread_cwd", "latest_version", @@ -85962,7 +87622,6 @@ "leaving_alternate_screen_repaints_restored_inline_viewport", "leaving_conversation_ultra_in_default_clears_the_ephemeral_plan_effort", "leaving_conversation_ultra_in_plan_clears_the_ephemeral_default_effort", - "left_fits", "left_navigates_only_from_an_empty_prompt", "left_respects_attachments_pastes_and_other_input_surfaces", "left_respects_editor_and_vim_history_remaps", @@ -86011,7 +87670,6 @@ "line_display_width", "line_number_width", "line_texts", - "line_to_owned", "line_to_text", "lines_at", "lines_to_plain_strings", @@ -86224,7 +87882,6 @@ "maps_install_context_to_update_action", "mark_agent_picker_thread_closed", "mark_available", - "mark_budget_limited", "mark_exhausted", "mark_external_writer", "mark_failed", @@ -86312,7 +87969,6 @@ "maybe_show_pending_rate_limit_prompt", "mcp", "mcp_apps_readiness_retries_an_in_flight_installed_mention_lookup", - "mcp_auth_status_label", "mcp_inventory_connection_states", "mcp_inventory_loading_snapshot", "mcp_inventory_loading_without_animations_is_stable", @@ -86454,11 +88110,9 @@ "move_down_without_selection_change_does_not_fire_callback", "move_down_wrap", "move_highlight", - "move_question", "move_selected_item", "move_selection_next", "move_selection_prev", - "move_to_display_col_on_line", "move_up_wrap", "moving_highlight_matches_full_rebuild_with_live_tail", "moving_highlight_preserves_unaffected_height_caches", @@ -86585,7 +88239,6 @@ "next_check_deadline", "next_copy_selection", "next_element_id", - "next_frame_delay", "next_goal_draft", "next_goal_objective", "next_history_message", @@ -86724,7 +88377,6 @@ "ok_page", "old_local_daemon_worktree_error_suggests_update", "older_external_server_starts_without_unsupported_dynamic_tools_or_history", - "older_pagination_completion_footers_follow_answers_without_overlap_duplicates", "older_pagination_reconciles_review_prompts_across_page_boundaries", "older_server_notice_falls_back_in_short_overview", "older_server_notice_is_visible_in_agents_overview", @@ -87187,11 +88839,9 @@ "paths_match", "pause", "pause_active_goal_for_interrupt", - "pause_at", "pause_for_disconnect", "pause_status_timer_for_modal", "pause_unavailable_thread", - "pauses_and_resumes_without_counting_wait_time", "pending", "pending_batch", "pending_device_code_state", @@ -87536,7 +89186,6 @@ "post_tool_use_hook_events_render_snapshot", "powershell_single_quoted_string", "powershell_skill_read_snapshot", - "pre_draw_tick", "pre_draw_tick_at", "pre_tool_use_hook_events_render_snapshot", "preamble_keeps_working_status_snapshot", @@ -88307,7 +89956,6 @@ "render_clears_area_when_cell_content_shrinks", "render_comfortable_session_lines", "render_content", - "render_context_right", "render_continue_in_browser", "render_conversation_preview_lines", "render_debug", @@ -88325,6 +89973,7 @@ "render_footer_with_mode_indicator_and_context", "render_fragment", "render_frame", + "render_header", "render_highlights_apply_style_without_mutating_text", "render_hints", "render_history_cell_lines", @@ -88379,7 +90028,6 @@ "render_pet_image", "render_pet_picker_preview_image", "render_pick_mode", - "render_picker_footer", "render_picker_footer_separator", "render_picker_from_view", "render_picker_list", @@ -88464,7 +90112,6 @@ "renders_mcp_tool_handler_with_server_and_tool_details", "renders_prompt", "renders_prompt_with_trust_error", - "renders_remapped_interrupt_hint", "renders_restricted_folder", "renders_review_needed_handler", "renders_scrolled_handler_window", @@ -88477,7 +90124,6 @@ "renders_truncated", "renders_untrusted_enabled_handler_as_inactive", "renders_with_working_header", - "renders_without_spinner_when_animations_disabled", "renders_wrapped_details_panama_two_lines", "reopening_identical_unified_mention_restarts_file_search", "repeat_aborts_when_change_motion_cannot_start", @@ -88703,7 +90349,6 @@ "reset_safety_buffering_for_turn_start", "reset_search", "reset_status_timer", - "reset_thread", "reset_thread_event_state", "reset_thread_event_state_aborts_listener_tasks", "reset_transcript_state_after_clear", @@ -88792,7 +90437,6 @@ "restore_reasoning_status_header", "restore_reconnected_input", "restore_retry_status_header_if_present", - "restore_running", "restore_runtime_permissions", "restore_runtime_theme_from_config", "restore_side_user_message", @@ -88821,7 +90465,6 @@ "restricted_acceptance_and_cancellation_do_not_persist_trust", "result_kind", "resumable_thread", - "resume_at", "resume_begin", "resume_config_for_target", "resume_end", @@ -88905,7 +90548,6 @@ "rgb332_color", "rgb332_index", "rgb_to_xyz", - "right_aligned_x", "right_footer_line_with_context", "rolling_credits_preserve_depleted_workspace_error_routing", "rolling_rate_limit_snapshot_preserves_prior_individual_limit", @@ -89236,7 +90878,6 @@ "set_active_reasoning_effort", "set_active_reasoning_effort_baseline", "set_active_turn_id", - "set_agents_navigation_enabled", "set_all_enabled", "set_alt_screen_enabled", "set_ambient_pet_notification", @@ -89313,7 +90954,6 @@ "set_input_enabled", "set_input_record_mode", "set_interrupt_binding", - "set_interrupt_hint_visible", "set_interrupted_turn_notice_mode", "set_key_chord_hint_override", "set_keymap", @@ -89512,7 +91152,6 @@ "should_show_matches", "should_show_onboarding", "should_show_trust_screen", - "should_stack", "should_suppress_animations", "should_suppress_printable_quit", "should_wrap_name_in_column", @@ -89624,7 +91263,6 @@ "single_line_command_over_highlight_limit_uses_plain_text_fallback", "single_line_command_wraps_with_four_space_continuation", "single_line_final_answer_hides_working_status_snapshot", - "single_line_footer_layout", "single_line_row_display_truncates_instead_of_wrapping", "single_question_spacing_with_working_status", "single_reasoning_option_skips_selection", @@ -89820,7 +91458,6 @@ "snapshot_rows", "snapshot_thread_switch_discards_queued_previous_history", "snapshot_view", - "snooze_auto_resolution", "socket_error", "soft_break", "soften_rgb_channel", @@ -89895,7 +91532,6 @@ "ssh_uses_osc52_and_skips_native_on_success", "stable_prefix_len_for_source_start", "stable_version", - "stacked_description", "stacked_side_content", "stacked_side_content_is_used_when_side_by_side_does_not_fit", "stage_selected_slash_command_history", @@ -89922,7 +91558,6 @@ "start_active_goal_turn", "start_add_credits_nudge_email_request", "start_agents_daemon", - "start_and_finish_update_running_state", "start_api_key_entry", "start_app_server", "start_app_server_for_picker", @@ -90208,8 +91843,6 @@ "status_snapshot_truncates_in_narrow_terminal", "status_snapshot_uses_default_reasoning_when_config_empty", "status_snapshot_uses_generic_limit_labels_for_unsupported_windows", - "status_style", - "status_style_for", "status_summary_line", "status_summary_spans", "status_surface_hostname_preview_uses_current_machine_hostname", @@ -90478,8 +92111,6 @@ "table_separator_blends_toward_dark_background", "table_separator_blends_toward_light_background", "table_separator_dims_when_palette_aware_color_is_unavailable", - "table_separator_style", - "table_separator_style_for", "table_url_with_tilde_keeps_complete_hyperlink", "table_widths_count_halfwidth_sound_marks", "tabs_enabled", @@ -90489,7 +92120,6 @@ "tail_starts_stream", "take_active_cell", "take_backfill_counts", - "take_budget_limited", "take_completed_persistent_runs", "take_completed_token_activity_output", "take_event_screen_size", @@ -91112,7 +92742,6 @@ "update_command_is_only_suggested_for_implicit_local_daemon", "update_computer_activity", "update_connector_enabled", - "update_details", "update_diff_preserves_multiline_highlight_state_within_hunk", "update_due_hook_visibility", "update_elements_after_replace", @@ -91122,10 +92751,7 @@ "update_feature_flags_enabling_guardian_overrides_explicit_manual_review_policy", "update_feature_flags_enabling_guardian_selects_auto_review", "update_from_arg_matches", - "update_header", "update_history_search_query", - "update_hook_status_message", - "update_inline_message", "update_inline_viewport_for_resize_reflow", "update_last_content_height", "update_layout_area", @@ -91193,7 +92819,6 @@ "user_input_too_large_message", "user_markdown_keeps_directive_literal", "user_message_bg", - "user_message_bg_rgb", "user_message_count", "user_message_display_for_history", "user_message_display_from_inputs", @@ -91204,8 +92829,6 @@ "user_message_for_restore", "user_message_from_submission", "user_message_preview_text", - "user_message_style", - "user_message_style_for", "user_positions_iter", "user_prompt_submit_app_server_hook_notifications_render_snapshot", "user_shell_command_renders_output_not_exploring", @@ -91273,8 +92896,6 @@ "version_is_at_least", "version_json", "version_matches", - "vertical_navigation_clamps_saved_column_after_resize", - "vertical_navigation_preserves_destination_tab_columns", "view", "view_footer", "view_id", @@ -91497,7 +93118,6 @@ "windows_terminal_uses_full_screen_unless_zellij_is_active", "windows_terminal_wt_session_promotes_to_truecolor", "windows_toml_from_json", - "with_border", "with_border_internal", "with_border_with_inner_width", "with_chat_widget_frame", @@ -91565,7 +93185,6 @@ "wrap_indent", "wrap_navigation_and_visibility", "wrap_row_lines", - "wrap_stacked_row", "wrap_standard_row", "wrap_styled_line", "wrap_styled_spans", @@ -91579,7 +93198,6 @@ "wrap_width", "wrap_with_prefix", "wrapped_and_prefixed_cells_handle_tiny_widths", - "wrapped_details_lines", "wrapped_list_item_is_separated_from_next_sibling", "wrapped_navigation_across_visual_lines", "wrapped_navigation_with_newlines_and_spaces", @@ -91681,6 +93299,7 @@ "FileSearchPopupAction", "FileSearchRequest", "HistoryTerminal", + "InitialHistoryPage", "ListKind", "Locale", "McpField", @@ -91701,6 +93320,8 @@ "PickerTranscriptPager", "ReconnectedSession", "Renderer", + "ScreenChunks", + "SelectionRow", "SettingsCommand", "SkillPopupAction", "StartupSessionState", @@ -91709,14 +93330,22 @@ "ThreadPage", "TranscriptEntry", "TranscriptHistoryCell", + "TranscriptViewport", "TuiOptions", "ViewportState", "Vt100HistoryTerminal", "WebSearchLifecycle", + "a_new_turn_can_create_a_streaming_item_after_a_terminal_turn", + "accent_is_palette_aware_and_has_a_no_color_fallback", "accepted_model_migration_updates_model_and_effort", + "active_agent_context", "active_bottom_pane_receives_input_before_the_chat_composer", + "active_draft_prefers_queue_hint_and_hides_context_when_narrow", "active_permission_profile", "active_permission_profile_requires_a_non_empty_id", + "active_status_height_and_width_are_bounded_for_supported_locales", + "active_status_message", + "active_turn_can_interrupt", "active_turn_elapsed", "active_turn_status_does_not_overflow_a_tiny_terminal", "active_turn_status_precedes_canonical_queue_and_composer", @@ -91757,9 +93386,11 @@ "ansi256_palette_contains_cube_and_grayscale", "ansi_alpha_markers_map_to_terminal_colors", "ansi_family_foreground_palette", + "answer", "append_delta", "append_item_preview_lines", "append_prompt_history", + "append_reasoning_section_break", "append_vim_indicator", "apply_add_block", "apply_delete_block", @@ -91777,8 +93408,11 @@ "apply_update_block_wraps_long_lines_text", "apply_update_with_rename_block", "apply_vim_text_object", + "approval_controls", "approval_fields", "approval_hides_an_open_slash_command_popup", + "approval_narrow_layout_keeps_primary_controls_visible", + "approval_navigation_wraps_and_accepts_control_bindings", "approval_option", "approval_replaces_the_composer_with_actionable_options", "approval_title", @@ -91793,8 +93427,14 @@ "at_token_range_tracks_cursor_boundaries_without_crossing_whitespace", "at_token_range_uses_utf8_safe_byte_ranges", "attach", + "attachment_edit_exits_history_navigation", "attachment_edit_is_undoable_without_text_changes", + "attachment_only_draft_does_not_enter_text_history", "attachments_are_ordered_and_restorable", + "attention_style", + "auto_resolution_countdown", + "auto_resolution_countdown_covers_all_product_locales", + "auto_resolution_countdown_text", "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", @@ -91813,9 +93453,13 @@ "canonical_ids_filter_cross_page_review_messages_without_text_matching", "canonical_projection_adapter_preserves_item_identity_and_text", "capture_current_thread_input", + "centered_popup", + "centered_popup_is_clamped_and_keeps_terminal_anchor", + "change_model_hint", "changed_width_is_debounced_and_due_is_observable", "chars", "chronological_turns", + "cjk_and_emoji_context_widths_do_not_overlap_left_hint", "clear_command_popup", "clear_dismissal", "clear_exec_approval_item", @@ -91860,6 +93504,8 @@ "commentary_agent_messages_are_visible_but_not_final_answers", "commit", "commit_current_field", + "compact_control_segment", + "compact_inventory_matches_codex_summary_shape", "compact_json", "compact_text", "comparable_path", @@ -91873,6 +93519,7 @@ "completed_turn_repair_keeps_web_search_completed_label", "completion_after", "completion_separator_localizes_elapsed_label", + "composer_placeholder", "composite_history_cell_separates_non_empty_parts", "computer_activity_bounds_multiline_titles_before_projection", "computer_activity_bounds_provider_diagnostics", @@ -91882,6 +93529,8 @@ "connect_session", "constrained_halfwidth_table_keeps_display_width_bounded", "constrained_tables_wrap_without_exceeding_available_width", + "contains_cancel_hint", + "contains_submit_hint", "content_previews", "content_previews_cap_items_and_length", "content_spans", @@ -91891,6 +93540,8 @@ "continuation_style", "control", "control_and_alt_characters_are_not_plain_text", + "control_j_and_k_navigate_options_without_opening_notes", + "control_navigation_matches_codex_list_bindings", "control_p_and_control_n_cycle_selection", "copy_last_response_with", "copy_shortcut_and_slash_command_do_not_become_turn_input", @@ -91915,6 +93566,10 @@ "current_at_token_range", "current_dollar_token_range", "current_line_range", + "current_prefixed_token_accepts_utf8_cursor_boundaries", + "current_prefixed_token_does_not_cross_trailing_horizontal_whitespace_into_plain_text", + "current_prefixed_token_handles_cursor_inside_utf8_codepoint_fail_closed", + "current_prefixed_token_preserves_separator_affinity_between_sigil_targets", "current_select_state_mut", "current_skill_token_range", "current_transcript_page_size", @@ -91930,6 +93585,7 @@ "cwds_differ_resolves_existing_symlink_targets", "cycle_permission_profile", "cycle_setting", + "dangerous_permissions_are_visible_without_becoming_a_fixed_header", "decimal_width", "decline_response", "declined_permissions", @@ -91939,13 +93595,16 @@ "delete_queued_submission", "delete_to_line_end", "deleted_raw_diff_keeps_source_extension_when_destination_is_dev_null", - "desired_height_with_locale", + "desired_height_with_locale_for_width", + "desired_height_with_messages", + "details_are_wrapped_and_bounded_by_max_lines", "diff_gallery_120x40", "diff_gallery_80x24", "diff_gallery_94x35", "diff_keeps_independent_multiline_syntax_state_for_old_and_new_files", "diff_uses_file_extension_for_syntax_highlighting", "direct_paste_replaces_stale_redo", + "disabled_reason_is_visible_and_width_bounded", "disabling_vim_cancels_an_active_query", "disconnected_edit_preserves_draft_and_cancels_history_preview", "disconnected_editing_preserves_graphemes_and_cursor_boundaries", @@ -91953,6 +93612,7 @@ "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", + "disconnected_submit_keys_clear_history_recall_state", "discovers_punctuated_and_balanced_web_urls", "dismiss_command", "dismissed_file_tokens_are_scoped_to_their_occurrence", @@ -91960,9 +93620,11 @@ "dispatch_connected_input", "dispatch_disconnected_input", "display_change_path", + "dollar_query_classifies_shell_parameter_edge_cases", "dollar_token_range_is_utf8_safe_and_rejects_embedded_prefixes", "down_selects_session_decision", "draft_content_equals", + "draft_ready_hint", "drain_snapshot", "dump_windows_clipboard_image", "dynamic_content_previews", @@ -91974,6 +93636,7 @@ "edit_draft_with_command", "edit_hint_requires_a_lossless_composer_projection", "edit_queued_input_hint", + "editor_display_width", "editor_failure_is_returned_without_leaking_the_tempfile", "editor_word_and_kill_shortcuts_share_textarea_semantics", "edits_draft_after_closing_the_tempfile_handle", @@ -91984,12 +93647,16 @@ "emit_skill_load_warnings", "emit_skill_load_warnings_projects_summary_and_details", "empty_catalog_has_no_mode", + "empty_composer_left_opens_agents_overview_when_local_navigation_is_enabled", + "empty_composer_left_stays_in_editor_when_navigation_is_disabled", + "empty_filter_keeps_catalog_order_and_narrow_render_is_bounded", "empty_notes_submit_the_selected_option", "empty_queries_and_missing_selection_do_not_match", "empty_query_has_no_matches", "empty_repeat_search_is_a_noop_and_backward_search_finds_previous_match", "empty_schema_decline_and_persisted_accept_are_typed", "empty_schema_uses_generic_approval_actions", + "empty_thread_starts_with_exactly_one_session_header", "empty_threads_produce_an_empty_canonical_transcript", "empty_transcript_is_rejected", "empty_undo_and_redo_are_consumed_in_normal_mode", @@ -92003,6 +93670,7 @@ "enter_collects_multiple_fields_and_emits_typed_content", "enter_executes_immediate_commands_and_completes_argument_commands", "entered", + "entry_status_style", "escape_cancels_instead_of_approving", "escape_interrupts_only_an_active_turn_and_preserves_the_draft", "escape_returns_an_empty_fail_closed_response", @@ -92010,6 +93678,7 @@ "evicted_request_does_not_replay", "exited", "expanded_transcript_uses_canonical_entries_and_stays_bounded", + "explicit_status_clears_reasoning_summary_header", "export_copy_description", "export_copy_label", "export_file_description", @@ -92032,6 +93701,7 @@ "fail_closed", "fail_thread_load", "failed_image_submission_can_restore_pending_attachments", + "failure_style", "fence_helpers_reject_short_or_indented_markers", "fence_tracker_tracks_markdown_other_and_blockquotes", "fenced_code_uses_the_first_info_token_for_syntax_highlighting", @@ -92079,9 +93749,13 @@ "flash_state_expires_without_mutating_mode", "flat_page_can_continue_an_existing_review_interval", "flat_page_only_filters_explicit_review_boundary", + "footer_control_lines", "footer_flash", "footer_has_draft", + "footer_hint_for_width", + "footer_hint_label_style_for", "footer_renders_the_active_agent_label", + "footer_styles_keep_labels_quiet_on_all_palettes", "for_composer", "form_surface_localizes_generated_labels_and_controls", "format_line", @@ -92092,6 +93766,8 @@ "from_name", "from_navigation", "from_server_request", + "full_inventory_preserves_resource_order_from_app_server", + "full_inventory_uses_codex_empty_tool_label", "fuzzy_file_search_request", "fuzzy_score", "grouped_history_does_not_move_completion_to_a_previous_visible_item", @@ -92119,9 +93795,10 @@ "hides_user_messages_inside_review_interval", "highlight_line", "highlighter_for", + "history_navigation_clears_completion_popups_for_recalled_text", + "history_navigation_does_not_replace_an_arbitrary_draft", "history_next", "history_previous", - "history_restores_the_unsent_draft", "history_search_cancel_restores_cursor_and_attachments", "history_search_cursor_position", "history_search_footer_cursor_tracks_query_and_clamps_to_narrow_width", @@ -92134,10 +93811,12 @@ "history_search_preview_highlights_matches_until_accepted", "history_search_query", "home_dir", + "hook_and_details_reflow_to_distinct_rows_without_dropping_the_header", "hook_completed", "hook_started", "hook_started_exposes_running_status_without_transcript_entry", - "hook_status", + "hook_status_message", + "hook_status_moves_to_a_second_line_when_the_header_does_not_fit", "hydrate_paginated_thread", "hydrate_thread", "hydrate_thread_hides_review_prompt_but_keeps_boundaries", @@ -92145,18 +93824,23 @@ "hydrate_thread_restores_items_and_active_turn_identity", "hydrated_thread_uses_the_same_phase_aware_final_answer_rule", "hyperlink_lines_with_locale", + "idle_app_does_not_interrupt", + "idle_composer_uses_codex_prompt_and_localized_placeholder", + "idle_footer_drops_ambient_agent_context_as_one_unit_when_it_cannot_fit", + "idle_footer_keeps_localized_shortcut_entry_point_across_supported_widths", "image_clipboard_statuses_cover_all_product_locales", "image_generation_status", "image_label", "image_shortcut_attaches_and_allows_image_only_submission", "image_view_paths_are_relative_to_the_current_working_directory", + "initial_history_page_uses_turn_metadata_for_filtering_and_completion", + "initial_history_page_without_turn_metadata_stays_item_only", "initialize_history_pagination", "initialize_params", "initialize_session", "inline_viewport_shrinks_into_terminal_bounds", "inline_viewport_stays_bottom_aligned_when_terminal_grows", "input_insert_mode", - "input_line_offset", "input_mode", "input_replace_mode", "insert", @@ -92164,25 +93848,32 @@ "insert_vim_replace_text", "insert_vim_search_text", "install_panic_hook", + "interactive_overlay_controls_cover_all_product_locales", + "interactive_overlays_remain_actionable_across_supported_widths_and_locales", "interrupt_hint", "interrupted_sub_agent_activity_stops_running_liveness", "invalid_percent_encoding_stays_visible", "invalidate_thread_list", "invalidate_viewport_sets_repaint_marker", "invalidate_wrap_cache", + "inventory_empty_state_is_localized", + "inventory_is_sorted_and_full_detail_includes_tool_counts_and_names", "invocation_text", "is_absolute_path", - "is_common_shell_variable", "is_editor_key_event", "is_empty_object_schema", "is_filename_prompt", + "is_horizontal_whitespace", + "is_input_line", "is_no_active_turn_error", + "is_option_line", "is_quiet_success", "is_searching", "is_select_field", "is_table_start", "is_text_field", "is_transcript", + "is_vertical_boundary", "item_kinds_have_distinct_terminal_layouts", "item_result_fields_become_structured_display_summaries", "item_started_clears_command_and_file_approval_by_item_id", @@ -92195,18 +93886,30 @@ "language_for_path", "languages_for_change_path", "last_known_cursor_position", + "late_agent_delta_does_not_reopen_completed_item", + "late_reasoning_section_boundary_does_not_mutate_completed_history", "leave_alternate_screen", "leaving_alternate_without_saved_area_is_a_noop", + "left", + "left_key_returns_open_agents_overview_only_when_available", + "left_navigation_does_not_steal_active_vim_search", + "left_navigation_does_not_steal_popup_or_vim_operator_input", + "left_navigation_is_disabled_by_default_and_for_remote_sessions", + "left_navigation_requires_an_empty_unblocked_composer", "legacy_agent_messages_without_phase_remain_final_answers", "len", + "lifecycle_status_suffixes_use_the_shared_semantic_tones", "line_end", "line_navigation_stays_inside_the_current_logical_line", "line_start", "line_style", "lines_with_locale", + "lines_with_locale_inner", "lines_with_locale_unbounded", "lines_with_locale_with_width", + "lines_with_locale_with_width_at", "list_collaboration_modes", + "list_mcp_server_statuses", "list_models", "list_permission_profiles", "list_queued_submissions", @@ -92224,8 +93927,14 @@ "loaded_history_is_bounded_to_the_recent_entries", "local_file_links_collapse_matching_labels_and_keep_descriptions", "local_file_links_preserve_tilde_unc_and_invalid_percent_spelling", + "localized_status_keeps_interrupt_hint_and_width_bound", "localized_tool_detail", + "long_agent_catalog_keeps_selected_row_inside_bounded_popup", + "long_catalog_keeps_selected_row_visible_with_a_bounded_popup", "long_diff_lines_wrap_with_an_aligned_continuation_gutter", + "long_model_catalog_keeps_selected_row_inside_bounded_popup", + "long_request_input_keeps_the_selected_option_inside_the_eight_row_window", + "long_resume_list_keeps_selected_session_visible_after_end_navigation", "long_single_line_falls_back_and_preserves_text", "map_composer_action", "maps_windows_temp_paths_for_wsl", @@ -92242,6 +93951,9 @@ "matching_path_labels_collapse_but_descriptive_labels_remain", "matching_ranges_stay_on_grapheme_boundaries", "mcp_approval_labels_cover_all_product_locales", + "mcp_auth_label", + "mcp_auth_status", + "mcp_connection_status_label", "mcp_content_previews", "mcp_elicitation_approval_option", "mcp_elicitation_boolean_option", @@ -92252,9 +93964,24 @@ "mcp_elicitation_text_placeholder", "mcp_elicitation_title", "mcp_entry_status", + "mcp_inventory_labels_cover_all_product_locales", + "mcp_inventory_lines", + "mcp_inventory_title", + "mcp_no_servers", + "mcp_no_tools_available", + "mcp_none", + "mcp_resource_templates_label", + "mcp_resources_label", + "mcp_slash_command_rejects_unknown_arguments_with_localized_usage", + "mcp_slash_commands_request_the_matching_inventory_detail", "mcp_startup_status_is_app_scoped_and_clears_when_ready", "mcp_startup_warning_state_clears_only_the_ready_server", + "mcp_status", "mcp_text_previews_are_compact_bounded_and_capped", + "mcp_tool_unit", + "mcp_tools_label", + "mcp_usage", + "mcp_use_verbose", "merge_canonical_items", "merge_export_entries", "message_entry", @@ -92268,14 +93995,17 @@ "model_route_requires_model_and_provider_together", "model_upgrade_target", "modified_enter_adds_a_newline_and_plain_enter_submits", + "modified_left_stays_in_the_editor", "move_insert_vertical", "move_left", "move_left_normal", "move_line_end", "move_line_start", + "move_option", "move_right", "move_right_normal", "move_vertical", + "move_visual_vertical", "move_word_forward", "multi_agent", "multi_agent_titles_are_localized_at_the_render_boundary", @@ -92284,8 +94014,15 @@ "multiline_wrap_returns_owned_lines", "multiple_hunks_use_a_vertical_ellipsis_instead_of_protocol_headers", "multiple_running_hooks_use_shared_message_or_plural_status", + "muted_style", "n_declines_exec_decision", + "narrow_footer_keeps_submit_and_cancel_before_secondary_hints", + "narrow_footer_wraps_actions_in_priority_order_for_all_locales", + "narrow_height_uses_the_same_width_as_rendered_content", + "narrow_multiline_text_keeps_physical_rows_and_cursor_aligned", "narrow_preview_wraps_and_caps_each_submission", + "narrow_rows_stack_description_without_splitting_graphemes", + "narrow_select_surface_bounds_rows_and_keeps_cancel_visible", "narrow_terminal_does_not_overflow_or_panic", "nearest_ansi256_color_is_indexed", "nearest_char_boundary", @@ -92297,11 +94034,17 @@ "next_page_is_hidden_while_loading", "next_transcript_cursor", "next_word_start", + "no_color_disables_semantic_colors_without_mutating_process_env", "no_questions", + "non_blocking_request", + "non_blocking_request_expires_with_empty_answers", + "non_blocking_request_is_snoozed_by_user_input", + "non_blocking_request_uses_hidden_grace_then_visible_countdown", "non_breaking_spaces_stay_inside_the_word", "non_context_outputs_are_bounded_and_compact", "non_empty", "non_interactive_responses_fail_closed_for_every_supported_interaction", + "non_key_and_key_release_events_do_not_change_popup_state", "non_successful_hooks_render_bounded_user_visible_summary", "normal_edit_undo_and_redo_restore_text", "normalize_cwd", @@ -92312,23 +94055,29 @@ "not_set_label", "note_outbound_response", "note_startup_protected_request", + "notes_focus_returns_to_options_without_submitting_on_escape_or_empty_backspace", "numbered_image_label", "observe_item", "observe_notification", "older_and_newer_matches_are_case_insensitive_and_bounded", + "older_page_reconciles_nested_review_prompt_from_adjacent_turn_metadata", "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_mcp_inventory", "open_model_picker", "open_status_pager", "open_transcript_pager", "option_count", "option_labels", "option_line", + "option_navigation_wraps_at_both_ends", "option_notes_follow_codex_answer_shape", "optional_string_property", + "options_typing_does_not_open_notes", "osc52_encoding_roundtrips_and_wraps_tmux", "osc52_rejects_oversized_payloads", + "other_enter_opens_notes_before_submitting_custom_answer", "other_option", "other_option_enabled", "other_option_is_only_added_when_the_contract_enables_it", @@ -92344,9 +94093,12 @@ "overview_rename_accepts_shifted_characters", "overview_render_is_bounded_on_wide_and_narrow_terminals", "overview_render_shows_localized_new_task_input_without_overflow", + "overview_render_uses_selection_row_for_status_and_current_context", "overview_search_filters_and_enter_selects", + "own_line", "own_lines", "owned_line", + "padded_label", "page_navigation_matches_codex_shape_and_reaches_the_next_cursor", "pager_footer", "paginated_hydration_fails_closed_when_item_turn_metadata_is_missing", @@ -92384,6 +94136,7 @@ "persist_temp_png", "persisted_items_share_the_live_projection_shape", "picker", + "picker_control_navigation_matches_codex_list_bindings", "picker_controls_match_codex_resume_shortcut_shapes", "picker_copy_and_status_render_for_every_product_locale", "picker_empty", @@ -92392,12 +94145,14 @@ "picker_filters_ephemeral_threads_and_navigates", "picker_filters_hidden_models_and_selects_default_first", "picker_footer", + "picker_navigation_wraps_and_accepts_control_bindings", "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", + "picker_vertical_constraints", "plain", "plain_char", "plan_and_diff_notifications_replace_stable_projection_entries", @@ -92409,7 +94164,10 @@ "popup_filter_tracks_the_cursor_inside_a_command_name", "popup_is_created_only_for_a_single_slash_token", "popup_state_has_single_active_command_popup", + "prefixed_candidate", "prepend_grouped_items_with_hidden_ids", + "prepend_history_page", + "prepend_initial_history_page", "prepend_items", "prepend_page_entries", "prepend_page_items", @@ -92423,9 +94181,11 @@ "preview_needs_load", "preview_uses_canonical_multimodal_queue_and_caps_visible_items", "previous_grapheme_start", + "previous_grapheme_start_at", "project_item", "project_item_with_lifecycle", "projected_item_id", + "projection_transition_starts_and_finishes_timer", "provider_label", "push_auto_link_span", "push_style", @@ -92437,8 +94197,13 @@ "query_changes_keep_previous_rows_visible_while_waiting", "query_mutation_invalidates_a_previous_page_cursor", "query_paste_does_not_consume_draft_redo", + "question_navigation_preserves_each_question_draft_and_selection", + "queue_and_draft_footer_hints_cover_all_product_locales", "queue_header_and_image_label_cover_all_product_locales", + "queue_hint_shortens_before_it_disappears", "queue_input", + "queue_message_hint", + "queue_short_hint", "queue_unavailable_status_covers_all_product_locales", "queued_skill_input_restores_as_an_editable_dollar_mention", "queued_submission_projection_updates_by_id_and_clears_on_thread_change", @@ -92453,6 +94218,8 @@ "real_stdio_unavailable_backend_fails_closed_without_provider", "realtime_review_boundary_hides_only_messages_inside_interval", "reasoning_markdown_keeps_reasoning_tone_and_inline_emphasis", + "reasoning_summary_parts_keep_streamed_section_boundaries", + "reasoning_summary_updates_running_status_with_latest_usable_line", "recognizes_unix_windows_unc_and_file_url_paths", "reconnect_session", "record_assistant_phase", @@ -92461,6 +94228,8 @@ "release_startup_input_boundary_if_ready", "reload_skills", "remap_transcript_anchor", + "remap_transcript_offset", + "remember_reasoning_status", "remote_image_rows_are_selectable_and_deletable_from_the_composer", "remote_images_require_selection_before_delete", "remote_images_support_selection_delete_and_round_trip", @@ -92477,10 +94246,14 @@ "render_pipe_fallback", "render_row", "render_separator", + "render_session_list", + "render_summary_footer", "render_transcript_entry_lines", "render_transcript_entry_lines_wrapped", + "render_transient_status", "render_unconstrained", "render_with_locale", + "render_with_messages", "rendered", "rendered_text_at_width", "renders_active_agent_context", @@ -92498,12 +94271,21 @@ "replace_entry", "replace_text", "replace_threads", + "request_all_older_history_pages", + "request_cancel_hint", + "request_notes_hint", + "request_question_nav_hint", + "request_select_hint", + "request_submit_hint", "request_unarchive_for_selected_session", "request_user_input", + "request_user_input_narrow_layout_keeps_submit_and_cancel_visible", + "request_with_options", "required_test_path", "required_validation_moves_to_missing_field_and_cancel_is_typed", "reset_discards_cursor_and_scan_progress", "reset_history_navigation", + "reset_transcript_anchor_at_top", "resize_keeps_non_bottom_aligned_viewport_position", "resize_recomputes_scroll_bounds_and_tiny_areas_do_not_panic", "resolve_editor_executable", @@ -92511,6 +94293,7 @@ "respond", "respond_current_time", "response_for_selected", + "restore_current_state", "restore_failure_preserves_archived_thread_and_allows_retry", "restore_pending_images", "restore_queued_submission_for_edit", @@ -92528,7 +94311,11 @@ "resume_loading", "resume_picker_controls_cover_all_product_locales", "resume_picker_owns_input_until_cancelled", + "resume_picker_render_matrix_stays_bounded_across_locales_and_widths", + "resume_picker_short_terminal_heights_remain_renderable", "resume_picker_title", + "resume_search_line", + "resume_search_line_preserves_toolbar_and_display_width_on_narrow_terminals", "resume_search_placeholder", "resume_slash_command_opens_the_shared_picker_action", "resume_sort_label", @@ -92550,6 +94337,8 @@ "run_session_picker_with_app_server", "run_tui", "rust_keywords_receive_terminal_palette_styles", + "safe_cursor", + "save_current_state", "save_text_draft", "scan_progress_accumulates_and_cap_is_sticky", "screen_chunks", @@ -92561,6 +94350,7 @@ "search_cancel_preserves_draft_and_clears_operator_pending", "search_command", "search_motion_can_complete_delete_operator", + "searchable_picker_keeps_plain_vim_letters_for_query_input", "secret_user_input_is_masked_in_the_test_backend", "select_fields_support_horizontal_navigation_and_digits", "select_history_match", @@ -92576,13 +94366,18 @@ "selection_wraps_and_enter_completes", "server_requests_expose_their_thread_target", "server_resolution_clears_request_identity_and_category", + "session_header_is_a_bounded_codex_shaped_box", + "session_header_labels_cover_all_product_locales", "session_id", + "session_list_item", "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_interrupt_hint", "set_locale", "set_model_catalog", + "set_older_history_available", "set_permission_profiles", "set_queued_submissions", "set_settings", @@ -92594,7 +94389,10 @@ "shell_parameters_do_not_open_skill_popup_without_an_exact_skill", "shifted_characters_are_plain_text", "shortcuts_cycle_codex_order_and_recover_unknown_values", + "shortcuts_hint", + "should_handle_history_navigation", "should_hide_realtime_item", + "should_interrupt_turn", "should_load_more", "single_line_replacement_counts", "skill", @@ -92618,12 +94416,14 @@ "soft_break_spaces_hang_on_the_previous_row", "spawn_archive_request", "spawn_file_search", + "spawn_marks_receiver_thread_as_parent_owned", "spawn_preview_load", "spawn_request_summary_preserves_model_and_effort", "spawn_thread_load", "spawn_transcript_load", "spawn_unarchive_request", "split_colon_location", + "split_input_lines", "splits_editor_command_without_losing_quotes", "ssh_uses_terminal_clipboard_and_local_prefers_native", "stale_request_cannot_finish_newer_load", @@ -92640,7 +94440,9 @@ "startup_protected_request_keeps_draft_until_the_request_is_resolved", "state_label", "stateful_render_clears_rows_outside_the_current_draft", + "static_overlay_never_requests_older_history", "static_overlay_wraps_scrolls_jumps_and_closes", + "status_and_footer_geometry_remains_stable_across_supported_widths_and_locales", "status_command_opens_an_ephemeral_pager_and_consumes_input_until_closed", "status_grouping", "status_line", @@ -92651,9 +94453,11 @@ "status_summary_text", "status_text", "status_title", + "status_tones_preserve_light_dark_and_no_color_semantics", "status_value", "stdio_config", "stdio_config_appends_host_arguments_after_the_current_runtime_default", + "stdout_color_level_for_env", "steer_turn_input", "stop", "stopped_activity_does_not_revive_after_liveness_terminal_state", @@ -92663,6 +94467,7 @@ "string_property", "strips_osc8_without_changing_visible_text", "styles", + "sub_agent_activity_updates_navigation_liveness", "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", @@ -92677,7 +94482,9 @@ "summary", "summary_keeps_bounded_text_and_structured_facts", "supports_request", + "switching_threads_resets_the_main_transcript_to_the_tail", "sync_default_collaboration_mode", + "sync_projection_turn", "sync_server_cwd", "sync_server_cwd_updates_only_for_a_different_normalized_path", "sync_view_state", @@ -92688,6 +94495,7 @@ "systemic_compact_fragmentation_uses_stacked_records", "tab_queues_a_follow_up_without_submitting_the_active_turn", "tab_queues_non_empty_draft_and_clears_composer", + "table_separator_blends_on_truecolor_and_dims_without_palette_support", "table_start_requires_header_and_delimiter", "tabs_and_blank_context_lines_preserve_diff_geometry", "tag", @@ -92696,11 +94504,15 @@ "take_refresh_pending", "take_submission", "take_thread_event_snapshot", + "taking_attachment_state_exits_history_navigation", "target", "temporary_clipboard_image_uses_png_suffix_and_bytes", "terminal_copy", "terminal_mut", "terminal_screen_text", + "terminal_turn_closes_unrepaired_stream_tail_against_late_delta", + "terminal_turn_rejects_late_delta_for_unknown_item", + "terminal_turn_rejects_late_item_started_but_accepts_canonical_completion", "test_backend_marks_markdown_links_with_osc8", "test_backend_renders_bounded_command_output_marker", "test_backend_renders_canonical_queue_between_transcript_and_composer", @@ -92758,9 +94570,14 @@ "transcript_load_event_records_loaded_and_failed_states", "transcript_overlay_preserves_manual_anchor_when_history_is_prepended", "transcript_overlay_preserves_manual_scroll_when_projection_grows", + "transcript_overlay_remaps_manual_anchor_when_streaming_line_grows_in_place", "transcript_overlay_remaps_manual_anchor_when_width_reflows", "transcript_overlay_renders_live_canonical_projection_with_markdown_and_links", + "transcript_overlay_requests_older_history_only_when_session_has_more_pages", + "transcript_overlay_requests_older_history_when_scrolled_to_the_top", + "transcript_overlay_reset_anchor_keeps_newly_loaded_beginning_visible", "transcript_overlay_starts_at_tail_follows_updates_and_closes_with_ctrl_t", + "transcript_overlay_without_older_history_consumes_top_navigation", "transcript_page_size", "transcript_page_size_tracks_resize", "transcript_pager_footer", @@ -92769,6 +94586,8 @@ "transcript_preview_text", "transcript_scroll_offset", "transcript_title", + "transient_status", + "transient_status_keeps_action_feedback_visible_without_idle_status_bar", "translates_statuses_and_dynamic_detail_labels_without_changing_unknown_text", "trim_trailing_separator", "truncate_command_output_line", @@ -92782,11 +94601,15 @@ "turn_completion_repairs_missing_streamed_items_from_canonical_turn", "turn_completion_replaces_streaming_item_with_canonical_text", "turn_label", + "turn_lookup_does_not_treat_an_unrelated_turn_as_the_item_page_target", "turn_lookup_keeps_paging_when_target_is_oldest_loaded_turn", "turn_lookup_requires_one_older_context_turn_after_targets", "turn_lookup_returns_chronological_order_for_history_filtering", "turn_status", "turn_terminal_status_settles_in_progress_items", + "ultra_narrow_footer_keeps_cancel_action_visible_without_overflow", + "unhandled_control_keys_do_not_pollute_model_query", + "unhandled_control_keys_do_not_pollute_resume_query", "unicode_cursor_and_grapheme_deletion_match_codex_textarea_semantics", "unicode_labels_remain_single_stable_row", "unified_diff_uses_stable_line_numbers_and_gutter_signs", @@ -92814,11 +94637,16 @@ "user_message", "user_message_id", "user_message_images_are_numbered_without_retaining_sources", + "user_message_surface_adapts_to_dark_light_and_no_color", "user_visible_header_labels_cover_all_product_locales", "validate_model_route", "vertical_ellipsis_between_hunks", "vertical_motion_preserves_display_column_for_wide_graphemes", + "vertical_navigation_preserves_preferred_column_across_short_wrapped_rows", "viewport_area", + "viewport_keeps_pinned_bottom_following_new_content", + "viewport_preserves_manual_anchor_when_stream_tail_grows", + "viewport_remaps_manual_anchor_when_width_reflows", "vim_find_and_till_stay_on_current_line_and_grapheme_boundaries", "vim_find_operators_delete_change_and_yank", "vim_insert_and_escape_preserve_normal_cursor_contract", @@ -92837,16 +94665,20 @@ "vim_search_owns_escape_and_paste_before_popup_or_active_turn", "vim_search_query", "vim_search_query_and_paste_do_not_mutate_the_draft", + "vim_search_suppresses_interrupt", "vim_search_wants_key", "vim_slash_command_toggles_composer_mode_and_projects_localized_status", "vim_text_object", "vim_text_objects_delete_inner_around_words_and_pairs", "visible_buffer_lines", + "visible_item_window", + "visible_item_window_keeps_selection_inside_eight_row_viewport", "visible_model", "visible_models", "visible_rows", "visible_terminal_text", "visible_terminal_text_ignores_cursor_controls", + "wait_for_any_marker", "wait_for_ledger_kind_and_scenario", "wait_for_marker", "wait_for_marker_after", @@ -92858,10 +94690,11 @@ "web_search_malformed_or_unknown_action_falls_back_to_query", "web_search_persisted_projection_is_status_agnostic", "web_search_realtime_lifecycle_uses_codex_started_and_completed_labels", + "whitespace_delimited_range", + "wide_rows_align_description_with_display_width", "width_allocation_preserves_compact_columns_after_token_heavy_columns", "windows_path_to_wsl", "with_locale", - "with_status_suffix", "word_end_cursor", "word_wrap_preserves_styled_spans_and_indent", "worked_label", @@ -92873,8 +94706,11 @@ "wrap_hyperlink_line", "wrap_plain_line", "wrap_ranges_are_utf8_safe", + "wrap_request_lines", + "wrap_row", "wrapped_line_starts", "wrapped_lines_keep_utf8_boundaries_and_reserve_cursor_rows", + "wrapped_single_line_vertical_navigation_stays_in_editor_until_visual_boundary", "wrapped_table_link_fragments_keep_complete_destination", "wrapped_transcript_scroll_uses_visual_rows", "wrapping_keeps_explicit_destination_on_every_fragment", @@ -92886,7 +94722,9 @@ "suite/directory_trust.rs", "suite/worktree_stack.rs" ], - "testFilesOnlyInLime": [] + "testFilesOnlyInLime": [ + "suite/history_pagination.rs" + ] }, "rules": [ "Codex TUI directory, module, type and function names are the baseline.", diff --git a/lime-rs/Cargo.lock b/lime-rs/Cargo.lock index 146c73f22..95b34175b 100644 --- a/lime-rs/Cargo.lock +++ b/lime-rs/Cargo.lock @@ -31,7 +31,7 @@ dependencies = [ [[package]] name = "agent-protocol" -version = "1.144.1" +version = "1.145.0" dependencies = [ "schemars", "serde", @@ -40,7 +40,7 @@ dependencies = [ [[package]] name = "agent-runtime" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "anyhow", @@ -170,7 +170,7 @@ dependencies = [ [[package]] name = "ansi-escape" -version = "1.144.1" +version = "1.145.0" dependencies = [ "ansi-to-tui", "ratatui", @@ -248,7 +248,7 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "app-server" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "app-server-client" -version = "1.144.1" +version = "1.145.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -330,7 +330,7 @@ dependencies = [ [[package]] name = "app-server-daemon" -version = "1.144.1" +version = "1.145.0" dependencies = [ "app-server-protocol", "app-server-transport", @@ -342,7 +342,7 @@ dependencies = [ [[package]] name = "app-server-protocol" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "schemars", @@ -354,7 +354,7 @@ dependencies = [ [[package]] name = "app-server-test-client" -version = "1.144.1" +version = "1.145.0" dependencies = [ "anyhow", "app-server-client", @@ -365,7 +365,7 @@ dependencies = [ [[package]] name = "app-server-transport" -version = "1.144.1" +version = "1.145.0" dependencies = [ "app-server-protocol", "axum", @@ -1010,7 +1010,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "cli" -version = "1.144.1" +version = "1.145.0" dependencies = [ "anyhow", "app-server-client", @@ -1062,7 +1062,7 @@ dependencies = [ [[package]] name = "code-mode" -version = "1.144.1" +version = "1.145.0" dependencies = [ "anyhow", "code-mode-host", @@ -1080,7 +1080,7 @@ dependencies = [ [[package]] name = "code-mode-host" -version = "1.144.1" +version = "1.145.0" dependencies = [ "axum", "code-mode-protocol", @@ -1096,7 +1096,7 @@ dependencies = [ [[package]] name = "code-mode-protocol" -version = "1.144.1" +version = "1.145.0" dependencies = [ "prost", "protoc-bin-vendored", @@ -1110,7 +1110,7 @@ dependencies = [ [[package]] name = "code-mode-runtime" -version = "1.144.1" +version = "1.145.0" dependencies = [ "base64 0.22.1", "code-mode-protocol", @@ -1935,7 +1935,7 @@ dependencies = [ [[package]] name = "document-preview" -version = "1.144.1" +version = "1.145.0" dependencies = [ "flate2", "tempfile", @@ -2155,7 +2155,7 @@ dependencies = [ [[package]] name = "execpolicy" -version = "1.144.1" +version = "1.145.0" dependencies = [ "anyhow", "clap", @@ -3576,7 +3576,7 @@ dependencies = [ [[package]] name = "lime-agent" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "agent-runtime", @@ -3611,7 +3611,7 @@ dependencies = [ [[package]] name = "lime-config" -version = "1.144.1" +version = "1.145.0" dependencies = [ "async-trait", "lime-core", @@ -3627,7 +3627,7 @@ dependencies = [ [[package]] name = "lime-core" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "async-trait", @@ -3684,7 +3684,7 @@ dependencies = [ [[package]] name = "lime-gateway" -version = "1.144.1" +version = "1.145.0" dependencies = [ "aes", "async-trait", @@ -3712,7 +3712,7 @@ dependencies = [ [[package]] name = "lime-infra" -version = "1.144.1" +version = "1.145.0" dependencies = [ "chrono", "dashmap 5.5.3", @@ -3734,7 +3734,7 @@ dependencies = [ [[package]] name = "lime-knowledge" -version = "1.144.1" +version = "1.145.0" dependencies = [ "chrono", "hex", @@ -3747,7 +3747,7 @@ dependencies = [ [[package]] name = "lime-mcp" -version = "1.144.1" +version = "1.145.0" dependencies = [ "async-trait", "axum", @@ -3773,7 +3773,7 @@ dependencies = [ [[package]] name = "lime-media-runtime" -version = "1.144.1" +version = "1.145.0" dependencies = [ "axum", "base64 0.22.1", @@ -3792,7 +3792,7 @@ dependencies = [ [[package]] name = "lime-processor" -version = "1.144.1" +version = "1.145.0" dependencies = [ "async-trait", "lime-core", @@ -3832,7 +3832,7 @@ dependencies = [ [[package]] name = "lime-server" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "async-stream", @@ -3875,7 +3875,7 @@ dependencies = [ [[package]] name = "lime-server-utils" -version = "1.144.1" +version = "1.145.0" dependencies = [ "axum", "futures", @@ -3890,7 +3890,7 @@ dependencies = [ [[package]] name = "lime-services" -version = "1.144.1" +version = "1.145.0" dependencies = [ "anyhow", "async-trait", @@ -3933,7 +3933,7 @@ dependencies = [ [[package]] name = "lime-skills" -version = "1.144.1" +version = "1.145.0" dependencies = [ "async-trait", "dirs 5.0.1", @@ -3955,7 +3955,7 @@ dependencies = [ [[package]] name = "lime-websocket" -version = "1.144.1" +version = "1.145.0" dependencies = [ "axum", "chrono", @@ -4289,7 +4289,7 @@ dependencies = [ [[package]] name = "model-provider" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "anyhow", @@ -5116,7 +5116,7 @@ checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "patch-apply" -version = "1.144.1" +version = "1.145.0" dependencies = [ "tempfile", "thiserror 1.0.69", @@ -6216,7 +6216,7 @@ dependencies = [ [[package]] name = "runtime-core" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -7477,7 +7477,7 @@ dependencies = [ [[package]] name = "thread-store" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "chrono", @@ -7804,7 +7804,7 @@ dependencies = [ [[package]] name = "tool-runtime" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "app-server-protocol", @@ -8009,7 +8009,7 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tui" -version = "1.144.1" +version = "1.145.0" dependencies = [ "agent-protocol", "ansi-escape", @@ -8020,6 +8020,7 @@ dependencies = [ "arboard", "base64 0.22.1", "crossterm", + "dirs 5.0.1", "futures", "image", "libc", diff --git a/lime-rs/Cargo.toml b/lime-rs/Cargo.toml index 0cca06783..c12bc6b6c 100644 --- a/lime-rs/Cargo.toml +++ b/lime-rs/Cargo.toml @@ -6,7 +6,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "1.144.1" +version = "1.145.0" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" diff --git a/lime-rs/crates/agent-runtime/src/request_user_input.rs b/lime-rs/crates/agent-runtime/src/request_user_input.rs index eeec098b6..55ec325f3 100644 --- a/lime-rs/crates/agent-runtime/src/request_user_input.rs +++ b/lime-rs/crates/agent-runtime/src/request_user_input.rs @@ -13,6 +13,7 @@ pub use tool_runtime::request_user_input::{ #[derive(Debug, Clone, PartialEq)] pub struct RequestUserInputRunRequest { pub request: RequestUserInputRequest, + pub is_blocking: bool, pub scope: Option, pub timeout: Duration, } @@ -20,11 +21,13 @@ pub struct RequestUserInputRunRequest { impl RequestUserInputRunRequest { pub fn new( request: RequestUserInputRequest, + is_blocking: bool, scope: Option, timeout: Duration, ) -> Self { Self { request, + is_blocking, scope, timeout, } @@ -35,6 +38,7 @@ impl RequestUserInputRunRequest { pub struct RequestUserInputAction { pub prompt: String, pub requested_schema: Value, + pub is_blocking: bool, pub auto_resolution_ms: Option, pub scope: Option, pub timeout: Duration, @@ -89,6 +93,7 @@ where let action = RequestUserInputAction { prompt: prompt.clone(), requested_schema: build_requested_schema(&run_request.request), + is_blocking: run_request.is_blocking, auto_resolution_ms: run_request.request.auto_resolution_ms, scope: run_request.scope, timeout: run_request.timeout, @@ -276,7 +281,7 @@ mod tests { let response = futures::executor::block_on(run_request_user_input( &gateway, - RequestUserInputRunRequest::new(request, scope.clone(), Duration::from_secs(5)), + RequestUserInputRunRequest::new(request, true, scope.clone(), Duration::from_secs(5)), )) .expect("request should run") .expect("response should normalize"); @@ -294,6 +299,7 @@ mod tests { let actions = actions.lock().expect("actions lock"); assert_eq!(actions.len(), 1); assert_eq!(actions[0].prompt, "请选择执行模式"); + assert!(actions[0].is_blocking); assert_eq!(actions[0].scope, scope); assert_eq!(actions[0].timeout, Duration::from_secs(5)); assert_eq!( diff --git a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs index 56886e3ce..3acdbd620 100644 --- a/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs +++ b/lime-rs/crates/agent/src/current_provider_turn/tool_executor.rs @@ -5,7 +5,7 @@ use crate::guardian_review; use crate::protocol::{AgentEvent, AgentToolProgressPayload}; use crate::request_tool_policy::RequestToolPolicy; use crate::runtime_state::{AgentRuntimeState, EffectivePermissionGrant}; -use agent_protocol::ThreadId; +use agent_protocol::{ModeKind, ThreadId}; use agent_runtime::session_loop::RuntimeSessionInputHandle; use futures::StreamExt; use rmcp::model::{CallToolResult, ErrorData, ServerNotification}; @@ -208,6 +208,7 @@ impl RuntimeToolExecutor for CurrentTurnToolExecutor { self.state.action_required_state(), response_handle, request_call_id, + request_user_input_is_blocking(request.turn_context), scope, self.event_sender.clone(), ); @@ -861,6 +862,14 @@ fn collect_text_fields(value: &Value, target: &mut Vec) { } } +fn request_user_input_is_blocking( + turn_context: Option<&tool_runtime::tool_executor::RuntimeToolTurnContext>, +) -> bool { + turn_context + .and_then(|context| context.collaboration_mode.as_ref()) + .is_none_or(|mode| mode.mode == ModeKind::Plan) +} + #[cfg(test)] mod tests { use super::*; @@ -872,6 +881,27 @@ mod tests { ResourceListChangedNotification, ToolListChangedNotification, }; + #[test] + fn request_user_input_blocking_follows_collaboration_mode() { + assert!(request_user_input_is_blocking(None)); + + let mut context = tool_runtime::tool_executor::RuntimeToolTurnContext { + collaboration_mode: Some(agent_protocol::CollaborationMode { + mode: ModeKind::Default, + settings: agent_protocol::CollaborationModeSettings::default(), + }), + ..Default::default() + }; + assert!(!request_user_input_is_blocking(Some(&context))); + + context + .collaboration_mode + .as_mut() + .expect("collaboration mode") + .mode = ModeKind::Plan; + assert!(request_user_input_is_blocking(Some(&context))); + } + #[test] fn shell_tool_approval_materializes_current_projection() { let metadata = HashMap::from([ diff --git a/lime-rs/crates/agent/src/request_user_input_bridge.rs b/lime-rs/crates/agent/src/request_user_input_bridge.rs index c85c7346d..5e0416025 100644 --- a/lime-rs/crates/agent/src/request_user_input_bridge.rs +++ b/lime-rs/crates/agent/src/request_user_input_bridge.rs @@ -57,6 +57,7 @@ impl RequestUserInputGateway for RuntimeActionRequiredGateway { "toolCallId": queued.tool_id, "prompt": queued.message, "questions": questions, + "isBlocking": action.is_blocking, "autoResolutionMs": action.auto_resolution_ms, "createdAtMs": queued.created_at_ms, "deadlineAtMs": queued.deadline_at_ms, @@ -75,6 +76,7 @@ pub(crate) fn create_request_user_input_callback( state: Arc, response_handle: RuntimeSessionInputHandle, item_id: String, + is_blocking: bool, scope: Option, event_sender: UnboundedSender, ) -> RequestUserInputCallback { @@ -87,6 +89,7 @@ pub(crate) fn create_request_user_input_callback( Box::pin(async move { let run_request = RequestUserInputRunRequest::new( request, + is_blocking, scope, Duration::from_secs(DEFAULT_REQUEST_USER_INPUT_TIMEOUT_SECS), ); @@ -190,6 +193,7 @@ mod tests { Arc::clone(&task_state), context.input_handle(), "item-request-user-input-1".to_string(), + false, Some(task_scope.clone()), event_sender.clone(), ); @@ -226,6 +230,7 @@ mod tests { }; assert_eq!(action_type, "ask_user"); assert_eq!(data["toolCallId"], "item-request-user-input-1"); + assert_eq!(data["isBlocking"], false); assert_eq!(data["autoResolutionMs"], 60_000); assert_eq!(event_scope, Some(scope.clone())); diff --git a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json index fd7705450..815bcc1be 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json +++ b/lime-rs/crates/app-server-protocol/schema/json/app_server_protocol.schemas.json @@ -64527,6 +64527,7 @@ "properties": { "autoResolutionMs": { "default": null, + "description": "@deprecated Use `isBlocking` to decide whether the request should block.", "format": "uint64", "minimum": 0, "type": [ @@ -64534,6 +64535,9 @@ "null" ] }, + "isBlocking": { + "type": "boolean" + }, "itemId": { "type": "string" }, @@ -64551,6 +64555,7 @@ } }, "required": [ + "isBlocking", "itemId", "questions", "threadId", @@ -108765,6 +108770,7 @@ "properties": { "autoResolutionMs": { "default": null, + "description": "@deprecated Use `isBlocking` to decide whether the request should block.", "format": "uint64", "minimum": 0, "type": [ @@ -108772,6 +108778,9 @@ "null" ] }, + "isBlocking": { + "type": "boolean" + }, "itemId": { "type": "string" }, @@ -108789,6 +108798,7 @@ } }, "required": [ + "isBlocking", "itemId", "questions", "threadId", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ServerRequest.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ServerRequest.json index 17f530285..64614a4e9 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ServerRequest.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ServerRequest.json @@ -567,6 +567,7 @@ "properties": { "autoResolutionMs": { "default": null, + "description": "@deprecated Use `isBlocking` to decide whether the request should block.", "format": "uint64", "minimum": 0, "type": [ @@ -574,6 +575,9 @@ "null" ] }, + "isBlocking": { + "type": "boolean" + }, "itemId": { "type": "string" }, @@ -591,6 +595,7 @@ } }, "required": [ + "isBlocking", "itemId", "questions", "threadId", diff --git a/lime-rs/crates/app-server-protocol/schema/json/v2/ToolRequestUserInputParams.json b/lime-rs/crates/app-server-protocol/schema/json/v2/ToolRequestUserInputParams.json index f0b590f00..e842f9d09 100644 --- a/lime-rs/crates/app-server-protocol/schema/json/v2/ToolRequestUserInputParams.json +++ b/lime-rs/crates/app-server-protocol/schema/json/v2/ToolRequestUserInputParams.json @@ -56,6 +56,7 @@ "properties": { "autoResolutionMs": { "default": null, + "description": "@deprecated Use `isBlocking` to decide whether the request should block.", "format": "uint64", "minimum": 0, "type": [ @@ -63,6 +64,9 @@ "null" ] }, + "isBlocking": { + "type": "boolean" + }, "itemId": { "type": "string" }, @@ -80,6 +84,7 @@ } }, "required": [ + "isBlocking", "itemId", "questions", "threadId", diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs index 76338a9b1..320b0acb4 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/tests.rs @@ -3012,6 +3012,7 @@ fn typed_v2_server_envelopes_fail_closed_for_unknown_methods() { {"label": "确认后执行", "description": "再次确认"} ] }], + "isBlocking": true, "autoResolutionMs": null } }); @@ -3028,6 +3029,22 @@ fn typed_v2_server_envelopes_fail_closed_for_unknown_methods() { user_input ); + let legacy_user_input: ServerRequest = serde_json::from_value(json!({ + "id": 9, + "method": "item/tool/requestUserInput", + "params": { + "threadId": "thread_1", + "turnId": "turn_1", + "itemId": "item_request_user_input", + "questions": [] + } + })) + .expect("decode legacy user input request"); + let ServerRequest::ItemToolRequestUserInput { params, .. } = legacy_user_input else { + panic!("expected user input request"); + }; + assert!(params.is_blocking); + let notification = ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread_1".to_string(), turn: Turn { diff --git a/lime-rs/crates/app-server-protocol/src/protocol/v2/user_input.rs b/lime-rs/crates/app-server-protocol/src/protocol/v2/user_input.rs index 0deefaa60..97babc748 100644 --- a/lime-rs/crates/app-server-protocol/src/protocol/v2/user_input.rs +++ b/lime-rs/crates/app-server-protocol/src/protocol/v2/user_input.rs @@ -1,5 +1,5 @@ use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use std::collections::BTreeMap; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -22,17 +22,47 @@ pub struct ToolRequestUserInputQuestion { pub options: Option>, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ToolRequestUserInputParams { pub thread_id: String, pub turn_id: String, pub item_id: String, pub questions: Vec, + pub is_blocking: bool, + /// @deprecated Use `isBlocking` to decide whether the request should block. #[serde(default)] pub auto_resolution_ms: Option, } +impl<'de> Deserialize<'de> for ToolRequestUserInputParams { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct WireToolRequestUserInputParams { + thread_id: String, + turn_id: String, + item_id: String, + questions: Vec, + is_blocking: Option, + auto_resolution_ms: Option, + } + + let wire = WireToolRequestUserInputParams::deserialize(deserializer)?; + Ok(Self { + thread_id: wire.thread_id, + turn_id: wire.turn_id, + item_id: wire.item_id, + questions: wire.questions, + is_blocking: wire.is_blocking.unwrap_or(true), + auto_resolution_ms: wire.auto_resolution_ms, + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ToolRequestUserInputAnswer { diff --git a/lime-rs/crates/app-server/src/approval_server_request.rs b/lime-rs/crates/app-server/src/approval_server_request.rs index 2f3e0c10a..037199907 100644 --- a/lime-rs/crates/app-server/src/approval_server_request.rs +++ b/lime-rs/crates/app-server/src/approval_server_request.rs @@ -494,6 +494,9 @@ fn user_input_request(event: &AgentEvent) -> Result, St turn_id: identity.turn_id.clone(), item_id, questions, + is_blocking: payload_value(&event.payload, &["isBlocking", "is_blocking"]) + .and_then(Value::as_bool) + .unwrap_or(true), auto_resolution_ms: payload_value( &event.payload, &["autoResolutionMs", "auto_resolution_ms"], @@ -780,6 +783,7 @@ mod tests { "data": { "actionType": "ask_user", "toolCallId": "item-ask-1", + "isBlocking": false, "autoResolutionMs": 60000, "questions": [{ "id": "mode", @@ -799,6 +803,7 @@ mod tests { panic!("ask_user must use tool request user input"); }; assert_eq!(request.params.item_id, "item-ask-1"); + assert!(!request.params.is_blocking); assert_eq!(request.params.auto_resolution_ms, Some(60000)); assert_eq!(request.params.questions[0].id, "mode"); assert_eq!( diff --git a/lime-rs/crates/app-server/src/runtime/provider_history.rs b/lime-rs/crates/app-server/src/runtime/provider_history.rs index 96c857cb5..ba38b954a 100644 --- a/lime-rs/crates/app-server/src/runtime/provider_history.rs +++ b/lime-rs/crates/app-server/src/runtime/provider_history.rs @@ -1144,6 +1144,32 @@ mod tests { event(sequence, event_type, json!({ "item": item })) } + fn canonical_review_boundary_event(sequence: u64, name: &str) -> AgentEvent { + let item = ThreadItem { + session_id: SessionId::new("session-1"), + thread_id: ThreadId::new("thread-1"), + turn_id: TurnId::new("turn-1"), + item_id: ItemId::new(format!("review-{name}")), + sequence, + ordinal: 1, + created_at_ms: 1, + updated_at_ms: 2, + completed_at_ms: Some(2), + kind: ItemKind::Extension, + status: ItemStatus::Completed, + payload: ThreadItemPayload::Extension { + name: name.to_string(), + data: json!({"review": "fixture review"}), + }, + metadata: Value::Null, + }; + event( + sequence, + crate::runtime::thread_fork::FORK_CANONICAL_ITEM_EVENT_TYPE, + json!({ "item": item }), + ) + } + #[test] fn canonical_history_preserves_tool_call_result_and_order() { let arguments = json!({ "path": "README.md" }); @@ -1196,6 +1222,21 @@ mod tests { )); } + #[test] + fn canonical_history_ignores_review_boundary_extensions() { + let messages = messages_from_events(&[ + canonical_review_boundary_event(1, "enteredReviewMode"), + event(2, "message.delta", json!({"text": "review output"})), + canonical_review_boundary_event(3, "exitedReviewMode"), + ]); + + assert_eq!(messages.len(), 1); + assert!(matches!( + &messages[0].content[..], + [CurrentProviderContent::Text(text)] if text == "review output" + )); + } + #[test] fn canonical_history_preserves_failed_tool_result() { let messages = messages_from_events(&[canonical_tool_event( diff --git a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs index 023f82dca..0fb333710 100644 --- a/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs +++ b/lime-rs/crates/app-server/src/runtime/provider_history/canonical.rs @@ -90,6 +90,8 @@ where flush_assistant(messages, assistant_content, assistant_text_by_item); tool_results.push(CurrentProviderContent::ToolResult(result)); } + ThreadItemPayload::Extension { name, .. } + if matches!(name.as_str(), "enteredReviewMode" | "exitedReviewMode") => {} ThreadItemPayload::CollabAgentToolCall { .. } | ThreadItemPayload::Media { .. } | ThreadItemPayload::ContextCompaction { .. } diff --git a/lime-rs/crates/app-server/src/runtime/thread_fork.rs b/lime-rs/crates/app-server/src/runtime/thread_fork.rs index 6e278ad5f..677c8ddd6 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_fork.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_fork.rs @@ -592,7 +592,22 @@ fn validate_fork_canonical_item(item: &ThreadItem) -> Result<(), RuntimeCoreErro ) })?; } - ThreadItemPayload::Unknown { .. } | ThreadItemPayload::Extension { .. } => { + ThreadItemPayload::Unknown { .. } => { + return Err(invalid( + "thread/fork cannot preserve unknown or extension provider history from canonical history", + )); + } + ThreadItemPayload::Extension { name, data } + if matches!(name.as_str(), "enteredReviewMode" | "exitedReviewMode") => + { + if data.get("review").and_then(Value::as_str).is_none() { + return Err(invalid(format!( + "thread/fork cannot preserve review extension {} without review text", + item.item_id + ))); + } + } + ThreadItemPayload::Extension { .. } => { return Err(invalid( "thread/fork cannot preserve unknown or extension provider history from canonical history", )); diff --git a/lime-rs/crates/app-server/src/runtime/thread_fork/tests.rs b/lime-rs/crates/app-server/src/runtime/thread_fork/tests.rs index a4530f38f..e9eb6fbe1 100644 --- a/lime-rs/crates/app-server/src/runtime/thread_fork/tests.rs +++ b/lime-rs/crates/app-server/src/runtime/thread_fork/tests.rs @@ -63,6 +63,13 @@ fn fork_rejects_canonical_history_that_cannot_be_lowered_without_loss() { }), "without a canonical result", ), + ( + completed_item(ThreadItemPayload::Extension { + name: "providerExtension".to_string(), + data: serde_json::json!({"value": true}), + }), + "unknown or extension provider history", + ), ]; for (item, expected) in cases { @@ -167,6 +174,25 @@ fn fork_accepts_image_input_preserved_by_canonical_user_message() { } } +#[test] +fn fork_accepts_known_review_boundary_extensions() { + for name in ["enteredReviewMode", "exitedReviewMode"] { + let item = completed_item(ThreadItemPayload::Extension { + name: name.to_string(), + data: serde_json::json!({"review": "fixture review"}), + }); + validate_fork_canonical_item(&item).expect("review boundary extension is forkable"); + } + + let malformed = completed_item(ThreadItemPayload::Extension { + name: "enteredReviewMode".to_string(), + data: serde_json::json!({}), + }); + let error = validate_fork_canonical_item(&malformed) + .expect_err("review boundary extension without text must fail closed"); + assert!(error.to_string().contains("without review text")); +} + #[test] fn fork_history_interrupts_only_the_default_trailing_turn() { let source = thread_with_turns(&[ diff --git a/lime-rs/crates/app-server/tests/thread_v2_jsonrpc.rs b/lime-rs/crates/app-server/tests/thread_v2_jsonrpc.rs index fec08ebe5..4b4ccc42d 100644 --- a/lime-rs/crates/app-server/tests/thread_v2_jsonrpc.rs +++ b/lime-rs/crates/app-server/tests/thread_v2_jsonrpc.rs @@ -1444,10 +1444,9 @@ async fn paginated_history_jsonrpc_preserves_canonical_thread_turn_item_identity ) .await; assert_eq!(turns["result"]["data"][0]["id"], json!(turn_id)); - assert_eq!( - turns["result"]["data"][0]["items"][1]["id"], - json!("answer-item") - ); + assert!(turns["result"]["data"][0]["items"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["id"] == "answer-item"))); let items = request( &server, diff --git a/lime-rs/crates/tui/Cargo.toml b/lime-rs/crates/tui/Cargo.toml index 44241334b..69e2ec7a5 100644 --- a/lime-rs/crates/tui/Cargo.toml +++ b/lime-rs/crates/tui/Cargo.toml @@ -19,6 +19,7 @@ app-server-protocol.workspace = true arboard.workspace = true base64.workspace = true crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] } +dirs.workspace = true futures.workspace = true image = { version = "0.25.9", default-features = false, features = ["jpeg", "png", "gif", "webp"] } ratatui = { workspace = true, features = [ diff --git a/lime-rs/crates/tui/src/app.rs b/lime-rs/crates/tui/src/app.rs index b183f9b92..d96770815 100644 --- a/lime-rs/crates/tui/src/app.rs +++ b/lime-rs/crates/tui/src/app.rs @@ -9,7 +9,9 @@ pub(crate) mod app_server_requests; pub(crate) mod event_dispatch; pub(crate) mod history_pagination; pub(crate) mod history_ui; -mod input; +mod input_flow; +mod input_submission; +mod interrupts; mod pending_interactive_replay; pub(crate) mod reconnect; mod replay_filter; @@ -20,27 +22,30 @@ pub(crate) mod startup_prompts; mod thread_event_buffer; mod thread_events; mod thread_settings; +mod tool_lifecycle; pub(crate) mod transcript_export; +mod turn_lifecycle; pub(crate) mod working_directory; -use app_server_protocol::protocol::v2::{QueuedSubmission, Thread}; +use app_server_protocol::protocol::v2::{ + McpServerStatus, McpServerStatusDetail, QueuedSubmission, Thread, +}; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use std::collections::HashMap; use std::path::PathBuf; -use std::time::{Duration, Instant}; +use std::time::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 self::transcript_export::{ExportPicker, ExportPickerAction}; -use crate::bottom_pane::{AppServerResponse, BottomPane, ChatComposer, InputResult}; -use crate::command_popup::CommandPopupAction; +use crate::bottom_pane::command_popup::CommandPopupAction; +use crate::bottom_pane::{AppServerResponse, BottomPane, ChatComposer}; 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::resume_picker::{PickerAction, PickerState}; use crate::slash_command::SlashCommand; @@ -70,6 +75,7 @@ pub(crate) enum AppAction { ScrollDown, ScrollTop, ScrollBottom, + LoadOlderHistory, SelectModel(ModelSelection), ChangeCollaborationMode(agent_protocol::CollaborationMode), SwitchThread(String), @@ -87,6 +93,9 @@ pub(crate) enum AppAction { }, OpenResumePicker, ResumePicker(PickerAction), + FetchMcpInventory { + detail: McpServerStatusDetail, + }, Respond(AppServerResponse), Quit, } @@ -125,6 +134,7 @@ pub(crate) struct App { pub(crate) permission_profiles: Vec, pub(crate) transcript_scroll: usize, pub(crate) scrollback_has_older_history: bool, + pub(crate) transcript_viewport: crate::transcript_reflow::TranscriptViewport, pub(crate) locale: Locale, pub(crate) cwd: PathBuf, pub(crate) clipboard_lease: Option, @@ -138,7 +148,7 @@ pub(crate) struct App { pub(crate) startup_protected_input_boundary: bool, pub(crate) startup_pending_protected_request: bool, external_editor_state: ExternalEditorState, - active_turn_started_at: Option, + turn_lifecycle: turn_lifecycle::TurnLifecycleState, } impl App { @@ -201,6 +211,11 @@ impl App { pub(crate) fn set_thread_id(&mut self, thread_id: String) { if self.thread_id.as_deref() != Some(thread_id.as_str()) { self.queued_submissions.clear(); + self.transcript_scroll = 0; + self.transcript_viewport.clear(); + self.turn_lifecycle.reset_thread(); + self.turn_lifecycle + .restore_running(self.projection.active_turn_id(), Instant::now()); } if self.primary_thread_id.is_none() { self.primary_thread_id = Some(thread_id.clone()); @@ -259,6 +274,9 @@ impl App { } pub(crate) fn hydrate_thread(&mut self, thread: Thread) { + self.transcript_scroll = 0; + self.transcript_viewport.clear(); + self.turn_lifecycle.reset_thread(); self.agent_navigation.upsert( thread.id.clone(), thread.agent_nickname.clone(), @@ -273,16 +291,8 @@ impl App { } self.projection.hydrate_thread(thread); self.scrollback_has_older_history = false; - self.active_turn_started_at = self - .projection - .active_turn_id() - .is_some() - .then(Instant::now); - } - - pub(crate) fn start_turn(&mut self, turn_id: String) { - self.projection.start_turn(turn_id); - self.active_turn_started_at = Some(Instant::now()); + self.turn_lifecycle + .restore_running(self.projection.active_turn_id(), Instant::now()); } fn adjacent_agent(&self, direction: AgentNavigationDirection) -> Option { @@ -291,15 +301,6 @@ impl App { .filter(|thread_id| self.thread_id.as_deref() != Some(thread_id.as_str())) } - pub(crate) fn active_turn_elapsed(&self, now: Instant) -> Option { - self.projection.active_turn_id()?; - Some( - self.active_turn_started_at - .map(|started_at| now.saturating_duration_since(started_at)) - .unwrap_or_default(), - ) - } - pub(crate) fn can_accept_direct_input(&mut self) -> bool { if self .thread_id @@ -336,6 +337,18 @@ impl App { self.external_editor_state = ExternalEditorState::Closed; } + pub(crate) fn pre_draw_tick(&mut self, now: Instant) -> AppAction { + let action = self + .bottom_pane + .pre_draw_tick(now) + .map(AppAction::Respond) + .unwrap_or(AppAction::None); + if matches!(action, AppAction::Respond(_)) && !self.bottom_pane.is_active() { + self.startup_pending_protected_request = false; + } + action + } + pub(crate) fn handle_tui_event(&mut self, event: TuiEvent, connected: bool) -> AppAction { if !connected { return match event { @@ -366,14 +379,19 @@ impl App { TuiEvent::Resize(size) => Event::Resize(size.width, size.height), TuiEvent::FocusGained => Event::FocusGained, TuiEvent::FocusLost => Event::FocusLost, - TuiEvent::Draw | TuiEvent::Resume => return AppAction::None, + TuiEvent::Draw => return self.pre_draw_tick(Instant::now()), + 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; - } - return AppAction::None; + return match pager.handle_event(&event) { + PagerAction::Close => { + self.pager_overlay = None; + AppAction::None + } + PagerAction::LoadOlderHistory => AppAction::LoadOlderHistory, + PagerAction::Consumed => AppAction::None, + }; } if let Some(picker) = self.export_picker.as_mut() { @@ -621,134 +639,6 @@ impl App { self.transcript_scroll = 0; } - pub(crate) fn attach_image(&mut self, path: PathBuf) { - self.composer.attach_image(path); - } - - pub(crate) fn take_pending_images(&mut self) -> Vec { - self.composer.take_pending_images() - } - - pub(crate) fn restore_pending_images(&mut self, images: Vec) { - self.composer.restore_pending_images(images); - } - - pub(crate) fn take_remote_image_urls(&mut self) -> Vec { - self.composer.take_remote_image_urls() - } - - pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { - self.composer.set_remote_image_urls(urls); - } - - pub(crate) fn set_queued_submissions(&mut self, submissions: Vec) { - self.queued_submissions = submissions; - } - - pub(crate) fn upsert_queued_submission(&mut self, submission: QueuedSubmission) { - if let Some(existing) = self - .queued_submissions - .iter_mut() - .find(|existing| existing.id == submission.id) - { - *existing = submission; - } else { - self.queued_submissions.push(submission); - } - } - - pub(crate) fn restore_queued_submission_for_edit( - &mut self, - submission: QueuedSubmission, - ) -> bool { - if !self.composer.is_empty() - || self.composer.has_pending_images() - || !can_restore_submission(&submission) - { - return false; - } - let submission_id = submission.id.clone(); - let mut text = String::new(); - let mut local_images = Vec::new(); - let mut remote_images = Vec::new(); - let mut skills = Vec::new(); - for input in submission.input { - match input { - app_server_protocol::protocol::v2::UserInput::Text { text: value, .. } => { - text = value; - } - app_server_protocol::protocol::v2::UserInput::LocalImage { path, .. } => { - local_images.push(PathBuf::from(path)); - } - app_server_protocol::protocol::v2::UserInput::Image { url, .. } => { - remote_images.push(url); - } - app_server_protocol::protocol::v2::UserInput::Skill { name, .. } => { - skills.push(format!("${name}")); - } - _ => return false, - } - } - self.queued_submissions - .retain(|queued| queued.id != submission_id); - if !skills.is_empty() { - let prefix = skills.join(" "); - text = if text.is_empty() { - prefix - } else { - format!("{prefix} {text}") - }; - } - self.replace_composer(text); - self.composer.restore_pending_images(local_images); - self.composer.set_remote_image_urls(remote_images); - self.clear_command_popup(); - true - } - - fn map_composer_action(&mut self, action: InputResult) -> AppAction { - match action { - InputResult::Submitted(text) => { - self.clear_command_popup(); - AppAction::Submit(text) - } - InputResult::Queued(text) => { - self.clear_command_popup(); - AppAction::Queue(text) - } - InputResult::Interrupt => { - let cleared = self.composer.clear_for_ctrl_c().is_some(); - if cleared { - self.clear_command_popup(); - // Codex treats Ctrl-C as composer cancellation when a draft is present. - // Do not also interrupt the active turn: a follow-up Ctrl-C can then be - // handled after the terminal projection settles. - AppAction::None - } else { - AppAction::Interrupt - } - } - InputResult::DecreaseEffort => AppAction::DecreaseEffort, - InputResult::IncreaseEffort => AppAction::IncreaseEffort, - InputResult::PreviousPermissions => AppAction::PreviousPermissions, - InputResult::NextPermissions => AppAction::NextPermissions, - InputResult::OpenExternalEditor => { - self.request_external_editor_launch(); - AppAction::None - } - InputResult::Quit => AppAction::Quit, - InputResult::Changed => { - if self.composer.history_search_active() || self.composer.vim_search_active() { - self.clear_command_popup(); - } else { - self.sync_command_popup(); - } - AppAction::None - } - InputResult::None => AppAction::None, - } - } - fn complete_slash_command(&mut self, command: SlashCommand) { let suffix = if command.requires_argument() { " " } else { "" }; self.composer @@ -801,6 +691,21 @@ impl App { AppAction::None } SlashCommand::Resume => AppAction::OpenResumePicker, + SlashCommand::Mcp => { + let argument = text.strip_prefix("/mcp").map(str::trim).unwrap_or_default(); + match argument { + "" => AppAction::FetchMcpInventory { + detail: McpServerStatusDetail::ToolsAndAuthOnly, + }, + "verbose" => AppAction::FetchMcpInventory { + detail: McpServerStatusDetail::Full, + }, + _ => { + self.projection.set_status(self.locale.mcp_usage()); + AppAction::None + } + } + } SlashCommand::Pwd => { if text.split_whitespace().count() != 1 { self.projection.set_status(self.locale.pwd_usage()); @@ -833,6 +738,18 @@ impl App { }, )); } + + pub(crate) fn open_mcp_inventory( + &mut self, + statuses: Vec, + detail: McpServerStatusDetail, + ) { + let lines = crate::history_cell::mcp_inventory_lines(&statuses, detail, self.locale); + self.pager_overlay = Some(PagerOverlay::new( + self.locale.mcp_inventory_title().to_string(), + lines, + )); + } } #[cfg(test)] diff --git a/lime-rs/crates/tui/src/app/agent_picker.rs b/lime-rs/crates/tui/src/app/agent_picker.rs index a49dd8aa1..a52a39872 100644 --- a/lime-rs/crates/tui/src/app/agent_picker.rs +++ b/lime-rs/crates/tui/src/app/agent_picker.rs @@ -5,15 +5,20 @@ use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{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::bottom_pane::selection_row_layout::{ + centered_popup, visible_item_window, wrap_row, SelectionDescriptionLayout, SelectionRow, + MAX_POPUP_ROWS, +}; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; use crate::multi_agents::format_agent_picker_item_name; +use crate::style::{accent_style, muted_style}; #[derive(Debug, Clone, PartialEq, Eq)] struct AgentPickerEntry { @@ -80,13 +85,24 @@ impl AgentPicker { } match key.code { KeyCode::Esc => AgentPickerAction::Cancel, - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); + KeyCode::Up | KeyCode::Char('p') | KeyCode::Char('k') + if key.code == KeyCode::Up + || key.modifiers.contains(KeyModifiers::CONTROL) => + { + if !self.entries.is_empty() { + self.selected = self + .selected + .checked_sub(1) + .unwrap_or(self.entries.len().saturating_sub(1)); + } AgentPickerAction::None } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Down | KeyCode::Char('n') | KeyCode::Char('j') + if key.code == KeyCode::Down + || key.modifiers.contains(KeyModifiers::CONTROL) => + { if !self.entries.is_empty() { - self.selected = (self.selected + 1).min(self.entries.len() - 1); + self.selected = (self.selected + 1) % self.entries.len(); } AgentPickerAction::None } @@ -109,12 +125,12 @@ impl AgentPicker { 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)); + let height = area + .height + .saturating_mul(3) + .saturating_div(4) + .clamp(7, MAX_POPUP_ROWS as u16 + 4); + let popup = centered_popup(area, width, height); frame.render_widget(Clear, popup); let chunks = Layout::default() @@ -137,8 +153,15 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, lo chunks[0], ); - let items = picker - .rows() + let row_width = chunks[1].width.saturating_sub(2); + let desc_col = usize::from(row_width.saturating_mul(2).saturating_div(5).max(1)); + let rows = picker.rows().collect::>(); + let max_visible = MAX_POPUP_ROWS.min(usize::from(chunks[1].height).max(1)); + let (start, end) = visible_item_window(picker.selected, rows.len(), max_visible); + let items = rows + .into_iter() + .skip(start) + .take(end.saturating_sub(start)) .map(|entry| { let status = if entry.is_closed { format!(" [{}]", locale.status("closed")) @@ -147,25 +170,30 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, lo } 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)), + let row = SelectionRow::new( + entry.label.clone(), + (!status.is_empty()).then_some(status), + Vec::new(), + ); + ListItem::new(wrap_row( + &row, + desc_col, + row_width, + SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: 10, + }, )) }) .collect::>(); let mut state = ListState::default(); if !items.is_empty() { - state.select(Some(picker.selected)); + state.select(Some(picker.selected.saturating_sub(start))); } 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("> "), + .highlight_style(accent_style()) + .highlight_symbol("› "), chunks[1], &mut state, ); @@ -175,10 +203,7 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, lo locale.agent_picker_footer() }; frame.render_widget( - Paragraph::new(Line::from(Span::styled( - footer, - Style::default().fg(Color::DarkGray), - ))), + Paragraph::new(Line::from(Span::styled(footer, muted_style()))), chunks[2], ); } @@ -186,6 +211,7 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, picker: &AgentPicker, lo #[cfg(test)] mod tests { use super::*; + use crossterm::event::KeyEvent; use ratatui::backend::TestBackend; use ratatui::Terminal; @@ -220,6 +246,44 @@ mod tests { ); } + #[test] + fn picker_navigation_wraps_and_accepts_control_bindings() { + let mut agent_picker = picker(); + + agent_picker.handle_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))); + assert_eq!( + agent_picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentPickerAction::Select("agent-1".to_string()) + ); + + let mut agent_picker = picker(); + agent_picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + ))); + assert_eq!( + agent_picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentPickerAction::Select("agent-1".to_string()) + ); + agent_picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::CONTROL, + ))); + assert_eq!( + agent_picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Enter, + KeyModifiers::NONE, + ))), + AgentPickerAction::Select("main".to_string()) + ); + } + #[test] fn picker_escape_cancels_and_narrow_render_stays_bounded() { let mut picker = picker(); @@ -276,4 +340,40 @@ mod tests { ); } } + + #[test] + fn long_agent_catalog_keeps_selected_row_inside_bounded_popup() { + let mut navigation = AgentNavigationState::default(); + for index in 0..20 { + navigation.upsert( + format!("agent-{index:02}"), + Some(format!("Agent {index:02}")), + Some("worker".to_string()), + false, + ); + } + let mut picker = AgentPicker::from_navigation(&navigation, None); + for _ in 0..12 { + picker.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); + } + + let mut terminal = Terminal::new(TestBackend::new(80, 20)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &picker, Locale::EnUs)) + .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"); + assert!( + text.contains("Agent 12"), + "selected agent was clipped: {text}" + ); + assert!(text.lines().all(|line| line.chars().count() <= 80)); + } } diff --git a/lime-rs/crates/tui/src/app/agents_overview_render.rs b/lime-rs/crates/tui/src/app/agents_overview_render.rs index 0c3bbbe4e..62d483a7c 100644 --- a/lime-rs/crates/tui/src/app/agents_overview_render.rs +++ b/lime-rs/crates/tui/src/app/agents_overview_render.rs @@ -1,7 +1,9 @@ //! 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::bottom_pane::selection_row_layout::{ + wrap_row, SelectionDescriptionLayout, SelectionRow, +}; use crate::locale::Locale; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -101,24 +103,35 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewVie .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() + let current = row.is_current.then_some("current"); + let description = if view.status_grouping() { + current.map(str::to_owned) } else { - format!( - " {} · {}", + let project = format!( + "{} · {}", locale.agents_overview_group_label(row.group.label()), row.thread.cwd.display() - ) + ); + Some(match current { + Some(current) => format!("{current} {project}"), + None => project, + }) }; - 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)), + let row = SelectionRow::new( + title, + description, + vec![Span::styled( + format!("{marker} "), + Style::default().fg(Color::Cyan), + )], + ); + ListItem::new(wrap_row( + &row, + usize::from(list_area.width.saturating_mul(3).saturating_div(5).max(1)), + list_area.width.saturating_sub(4), + SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: 16, + }, )) }) .collect::>(); @@ -134,7 +147,7 @@ pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, view: &AgentsOverviewVie .fg(Color::Cyan) .add_modifier(Modifier::BOLD), ) - .highlight_symbol("> "), + .highlight_symbol("› "), list_area, &mut state, ); @@ -319,4 +332,36 @@ mod tests { assert!(compact.contains("新建任务"), "{text}"); assert!(compact.contains("检查任务"), "{text}"); } + + #[test] + fn overview_render_uses_selection_row_for_status_and_current_context() { + let mut current = row("current"); + current.group = AgentsOverviewGroup::Ready; + current.is_current = true; + current.thread.cwd = PathBuf::from("/workspace/project"); + let view = AgentsOverviewView::new(vec![current], Some("current")); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &view, Locale::EnUs)) + .expect("draw"); + let text = terminal + .backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::(); + assert!(text.contains("○"), "selection row marker missing: {text}"); + assert!(text.contains("current"), "current context missing: {text}"); + assert!(text.contains("Ready"), "status description missing: {text}"); + let compact = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!(compact.contains("/"), "cwd prefix missing: {text}"); + assert!( + compact.contains("workspace/project"), + "cwd description missing: {text}" + ); + } } diff --git a/lime-rs/crates/tui/src/app/event_dispatch.rs b/lime-rs/crates/tui/src/app/event_dispatch.rs index f9d987515..2a3cd4acc 100644 --- a/lime-rs/crates/tui/src/app/event_dispatch.rs +++ b/lime-rs/crates/tui/src/app/event_dispatch.rs @@ -187,6 +187,15 @@ impl App { } Ok(EventDispatch::Handled) } + AppAction::FetchMcpInventory { detail } => { + match context.session.list_mcp_server_statuses(detail).await { + Ok(statuses) => self.open_mcp_inventory(statuses, detail), + Err(error) => self + .projection + .set_status(format!("MCP inventory failed: {error}")), + } + Ok(EventDispatch::Handled) + } other => Ok(EventDispatch::Unhandled(other)), } } diff --git a/lime-rs/crates/tui/src/app/history_pagination.rs b/lime-rs/crates/tui/src/app/history_pagination.rs index 17f8e923d..5b2644a8d 100644 --- a/lime-rs/crates/tui/src/app/history_pagination.rs +++ b/lime-rs/crates/tui/src/app/history_pagination.rs @@ -14,13 +14,28 @@ pub(crate) mod completion; impl App { /// Prepend the initial paginated page using the same optional Turn enrichment as older pages. pub(crate) fn prepend_initial_history_page(&mut self, page: InitialHistoryPage) { - if let Some(turns) = page.turns { - let hidden_ids = hidden_user_message_ids(&turns); - let groups = completion::group_completed_turn_items(page.items, &turns); + let InitialHistoryPage { items, turns } = page; + self.prepend_history_page(items, turns.as_deref()); + } + + /// Reconcile one page against the Turn metadata available for it and its adjacent context. + /// + /// An older App Server may initially expose only flat items. When a later page provides the + /// canonical Turn pair for a nested review, remove the now-classified prompt before prepending + /// the page so the projection never keeps a duplicate hidden entry. + fn prepend_history_page( + &mut self, + items: Vec, + turns: Option<&[Turn]>, + ) { + if let Some(turns) = turns { + let hidden_ids = hidden_user_message_ids(turns); + let groups = completion::group_completed_turn_items(items, turns); + self.projection.remove_hidden_entries(&hidden_ids); self.projection .prepend_grouped_items_with_hidden_ids(groups, &hidden_ids); } else { - self.projection.prepend_items(page.items); + self.projection.prepend_items(items); } } @@ -101,18 +116,32 @@ impl App { } }; let items = app_server.apply_older_history_page(thread_id, cursor, page)?; - if let Some(turns) = turns { - let hidden_ids = hidden_user_message_ids(turns); - self.projection.remove_hidden_entries(&hidden_ids); - let groups = completion::group_completed_turn_items(items, turns); - self.projection - .prepend_grouped_items_with_hidden_ids(groups, &hidden_ids); - } else { - self.projection.prepend_items(items); - } + self.prepend_history_page(items, turns); self.scrollback_has_older_history = app_server.has_older_history(thread_id); + if let Some(pager) = self.pager_overlay.as_ref() { + pager.set_older_history_available(self.scrollback_has_older_history); + } Ok(true) } + + /// Load every remaining older page after the transcript overlay reaches its beginning. + /// + /// Codex treats Home as a request for the complete historical beginning. Ordinary scrollback + /// still calls `request_older_history_page` once per key event, so large histories do not get + /// fetched eagerly during normal scrolling. + pub(crate) async fn request_all_older_history_pages( + &mut self, + app_server: &mut AppServerSession, + ) -> Result { + let mut loaded = 0; + while self.scrollback_has_older_history { + if !self.request_older_history_page(app_server).await? { + break; + } + loaded += 1; + } + Ok(loaded) + } } #[cfg(test)] @@ -200,4 +229,134 @@ mod tests { assert_eq!(app.projection.entries().len(), 2); assert!(app.projection.completion_after("answer").is_none()); } + + #[test] + fn older_pagination_completion_footers_follow_answers_without_overlap_duplicates() { + let newest_items = vec![ + user_message("new-prompt", "new"), + answer("new-answer", "new"), + ]; + let newest_turn = Turn { + id: "new-turn".to_string(), + items: newest_items.clone(), + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: Some(1), + completed_at: Some(4), + duration_ms: Some(3_000), + }; + let mut app = App::default(); + app.prepend_initial_history_page(InitialHistoryPage { + items: newest_items, + turns: Some(vec![newest_turn]), + }); + + let older_items = vec![ + user_message("old-prompt", "old"), + answer("old-answer", "old"), + answer("new-answer", "new"), + ]; + let old_turn = Turn { + id: "old-turn".to_string(), + items: older_items[..2].to_vec(), + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1_000), + }; + let overlapping_new_turn = Turn { + id: "new-turn".to_string(), + items: vec![answer("new-answer", "new")], + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: Some(3), + completed_at: Some(4), + duration_ms: Some(3_000), + }; + let groups = + completion::group_completed_turn_items(older_items, &[old_turn, overlapping_new_turn]); + app.projection + .prepend_grouped_items_with_hidden_ids(groups, &HashSet::new()); + + assert_eq!( + app.projection + .entries() + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["old-prompt", "old-answer", "new-prompt", "new-answer"] + ); + assert_eq!( + app.projection + .entries() + .iter() + .filter(|entry| app.projection.completion_after(&entry.id).is_some()) + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["old-answer", "new-answer"] + ); + } + + #[test] + fn older_page_reconciles_nested_review_prompt_from_adjacent_turn_metadata() { + let hidden = user_message("nested-hidden", "same prompt"); + let duplicate = user_message("nested-duplicate", "same prompt"); + let mut app = App::default(); + + // The initial item-only page can render a prompt before the server exposes Turn metadata. + app.prepend_initial_history_page(InitialHistoryPage { + items: vec![hidden.clone(), user_message("visible", "visible prompt")], + turns: None, + }); + + let previous_review_turn = Turn { + id: "review-turn".to_string(), + items: vec![ + app_server_protocol::protocol::v2::ThreadItem::EnteredReviewMode { + id: "review-enter".to_string(), + metadata: None, + review: "review".to_string(), + }, + app_server_protocol::protocol::v2::ThreadItem::ExitedReviewMode { + id: "review-exit".to_string(), + metadata: None, + review: "review".to_string(), + }, + ], + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1_000), + }; + let nested_review_turn = Turn { + id: "nested-turn".to_string(), + items: vec![hidden, duplicate], + items_view: TurnItemsView::Full, + status: TurnStatus::Interrupted, + error: None, + started_at: Some(3), + completed_at: None, + duration_ms: None, + }; + + app.prepend_history_page( + vec![user_message("older-visible", "older visible")], + Some(&[previous_review_turn, nested_review_turn]), + ); + + assert_eq!( + app.projection + .entries() + .iter() + .map(|entry| entry.id.as_str()) + .collect::>(), + vec!["older-visible", "visible"] + ); + } } diff --git a/lime-rs/crates/tui/src/app/history_ui.rs b/lime-rs/crates/tui/src/app/history_ui.rs index 23a1af657..31e3f5f6f 100644 --- a/lime-rs/crates/tui/src/app/history_ui.rs +++ b/lime-rs/crates/tui/src/app/history_ui.rs @@ -4,7 +4,9 @@ //! owned by App Server and `ConversationProjection`. use crate::app::App; -use crate::history_cell::{FinalMessageSeparator, HistoryCell, TranscriptHistoryCell}; +use crate::history_cell::{ + FinalMessageSeparator, HistoryCell, SessionHeaderHistoryCell, TranscriptHistoryCell, +}; use crate::locale::Locale; use crate::projection::TranscriptEntry; use crate::terminal_hyperlinks::{wrap_hyperlink_line, HyperlinkLine}; @@ -42,7 +44,19 @@ pub(crate) fn render_transcript_content_lines( viewport_width: u16, separate_entries: bool, ) -> Vec { - let mut lines = Vec::new(); + let content_width = viewport_width.saturating_sub(2).max(1); + let header = SessionHeaderHistoryCell::new( + app.model.as_deref().unwrap_or("auto"), + app.reasoning_effort.clone(), + app.permissions.clone(), + app.cwd.clone(), + env!("CARGO_PKG_VERSION"), + app.locale, + ); + let mut lines = header.display_hyperlink_lines(content_width); + if !app.projection.entries().is_empty() { + lines.push(HyperlinkLine::default()); + } for entry in app.projection.entries() { if separate_entries && !lines.is_empty() { lines.push(HyperlinkLine::default()); @@ -85,14 +99,47 @@ mod tests { ), ); let lines = render_transcript_content_lines(&app, 80, false); - assert_eq!(lines.len(), 1); - assert!(lines[0] + let header = lines + .iter() + .position(|line| line.line.to_string().contains("Lime")) + .expect("session header"); + let warning = lines + .iter() + .position(|line| { + line.line + .spans + .iter() + .any(|span| span.content.contains("warning")) + }) + .expect("warning"); + assert!(header < warning); + assert!(lines[warning] .line .spans .iter() .any(|span| span.content.contains("warning"))); } + #[test] + fn empty_thread_starts_with_exactly_one_session_header() { + let app = App { + locale: Locale::EnUs, + model: Some("fixture-model".to_string()), + cwd: "/workspace".into(), + ..App::default() + }; + let lines = render_transcript_content_lines(&app, 80, false); + let text = lines + .iter() + .map(|line| line.line.to_string()) + .collect::>() + .join("\n"); + + assert_eq!(text.matches(">_ Lime").count(), 1, "{text}"); + assert!(text.contains("fixture-model"), "{text}"); + assert!(text.contains("/workspace"), "{text}"); + } + #[test] fn transcript_entry_lines_share_cell_rendering_and_fit_viewport() { let entry = TranscriptEntry { diff --git a/lime-rs/crates/tui/src/app/input.rs b/lime-rs/crates/tui/src/app/input.rs index bb7d2a16c..5ab53b9e3 100644 --- a/lime-rs/crates/tui/src/app/input.rs +++ b/lime-rs/crates/tui/src/app/input.rs @@ -1,152 +1,6 @@ -//! 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() - && !self.composer.vim_search_active() => - { - 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.composer.clear_command_popup(); - 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 - && !self.composer.vim_search_active() - && !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.vim_search_active() - && 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.vim_search_active() - && self.composer.is_empty() - && !self.composer.has_selected_remote_image() - && 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) - } - } - } -} +//! Historical boundary for the former app-level input owner. +//! +//! Input routing now lives in [`super::input_flow`], which mirrors the Codex +//! chat-widget boundary and is the only registered implementation. This file +//! remains empty so old source references do not silently become a second +//! behavior owner. diff --git a/lime-rs/crates/tui/src/app/input_flow.rs b/lime-rs/crates/tui/src/app/input_flow.rs new file mode 100644 index 000000000..64d4247ad --- /dev/null +++ b/lime-rs/crates/tui/src/app/input_flow.rs @@ -0,0 +1,153 @@ +//! Keyboard input flow for the TUI app. +//! +//! This owner mirrors Codex `chatwidget/input_flow`: App owns global routing, +//! while ChatComposer remains the editor and popup owner. + +use super::*; +use crate::app::agent_navigation::AgentNavigationDirection; +use crate::bottom_pane::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 super::interrupts::should_interrupt_turn(self) => 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.composer.clear_command_popup(); + let pager = PagerOverlay::transcript(self.locale); + pager.set_older_history_available(self.scrollback_has_older_history); + self.pager_overlay = Some(pager); + 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 + && !self.composer.vim_search_active() + && !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.vim_search_active() + && 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.vim_search_active() + && self.composer.is_empty() + && !self.composer.has_selected_remote_image() + && 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/input_submission.rs b/lime-rs/crates/tui/src/app/input_submission.rs new file mode 100644 index 000000000..4a0f2a52e --- /dev/null +++ b/lime-rs/crates/tui/src/app/input_submission.rs @@ -0,0 +1,137 @@ +//! Submission lowering at the TUI boundary. +//! +//! This module owns the Lime subset of Codex `chatwidget/input_submission`: +//! composer results become app actions, canonical queued submissions can be +//! edited without losing attachments, and the composer owns attachment state. +//! Transport execution remains in the runtime and App Server session. + +use super::*; +use crate::bottom_pane::pending_input_preview::can_restore_submission; +use crate::bottom_pane::InputResult; +use app_server_protocol::protocol::v2::UserInput; + +impl App { + pub(crate) fn attach_image(&mut self, path: PathBuf) { + self.composer.attach_image(path); + } + + pub(crate) fn take_pending_images(&mut self) -> Vec { + self.composer.take_pending_images() + } + + pub(crate) fn restore_pending_images(&mut self, images: Vec) { + self.composer.restore_pending_images(images); + } + + pub(crate) fn take_remote_image_urls(&mut self) -> Vec { + self.composer.take_remote_image_urls() + } + + pub(crate) fn set_remote_image_urls(&mut self, urls: Vec) { + self.composer.set_remote_image_urls(urls); + } + + pub(crate) fn set_queued_submissions(&mut self, submissions: Vec) { + self.queued_submissions = submissions; + } + + pub(crate) fn upsert_queued_submission(&mut self, submission: QueuedSubmission) { + if let Some(existing) = self + .queued_submissions + .iter_mut() + .find(|existing| existing.id == submission.id) + { + *existing = submission; + } else { + self.queued_submissions.push(submission); + } + } + + pub(crate) fn restore_queued_submission_for_edit( + &mut self, + submission: QueuedSubmission, + ) -> bool { + if !self.composer.is_empty() + || self.composer.has_pending_images() + || !can_restore_submission(&submission) + { + return false; + } + let submission_id = submission.id.clone(); + let mut text = String::new(); + let mut local_images = Vec::new(); + let mut remote_images = Vec::new(); + let mut skills = Vec::new(); + for input in submission.input { + match input { + UserInput::Text { text: value, .. } => text = value, + UserInput::LocalImage { path, .. } => local_images.push(PathBuf::from(path)), + UserInput::Image { url, .. } => remote_images.push(url), + UserInput::Skill { name, .. } => skills.push(format!("${name}")), + _ => return false, + } + } + self.queued_submissions + .retain(|queued| queued.id != submission_id); + if !skills.is_empty() { + let prefix = skills.join(" "); + text = if text.is_empty() { + prefix + } else { + format!("{prefix} {text}") + }; + } + self.replace_composer(text); + self.composer.restore_pending_images(local_images); + self.composer.set_remote_image_urls(remote_images); + self.clear_command_popup(); + true + } + + pub(super) fn map_composer_action(&mut self, action: InputResult) -> AppAction { + match action { + InputResult::Submitted(text) => { + self.clear_command_popup(); + AppAction::Submit(text) + } + InputResult::Queued(text) => { + self.clear_command_popup(); + AppAction::Queue(text) + } + InputResult::Interrupt => { + let cleared = self.composer.clear_for_ctrl_c().is_some(); + if cleared { + self.clear_command_popup(); + // Codex treats Ctrl-C as composer cancellation when a draft is present. + // Do not also interrupt the active turn: a follow-up Ctrl-C can then be + // handled after the terminal projection settles. + AppAction::None + } else { + AppAction::Interrupt + } + } + InputResult::DecreaseEffort => AppAction::DecreaseEffort, + InputResult::IncreaseEffort => AppAction::IncreaseEffort, + InputResult::PreviousPermissions => AppAction::PreviousPermissions, + InputResult::NextPermissions => AppAction::NextPermissions, + InputResult::OpenExternalEditor => { + self.request_external_editor_launch(); + AppAction::None + } + InputResult::OpenAgentsOverview => { + self.open_agents_overview(); + AppAction::RefreshAgentsOverview + } + InputResult::Quit => AppAction::Quit, + InputResult::Changed => { + if self.composer.history_search_active() || self.composer.vim_search_active() { + self.clear_command_popup(); + } else { + self.sync_command_popup(); + } + AppAction::None + } + InputResult::None => AppAction::None, + } + } +} diff --git a/lime-rs/crates/tui/src/app/interrupts.rs b/lime-rs/crates/tui/src/app/interrupts.rs new file mode 100644 index 000000000..eabd39e6e --- /dev/null +++ b/lime-rs/crates/tui/src/app/interrupts.rs @@ -0,0 +1,41 @@ +//! Active-turn interruption policy for the TUI app. +//! +//! Interactive request queues remain owned by `BottomPane` and `pending_interactive_replay`. +//! This module only owns the Codex-shaped decision that an Esc event may interrupt the active +//! canonical turn. + +use super::App; + +pub(super) fn should_interrupt_turn(app: &App) -> bool { + app.projection.active_turn_id().is_some() && !app.composer.vim_search_active() +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn idle_app_does_not_interrupt() { + assert!(!should_interrupt_turn(&App::default())); + } + + #[test] + fn active_turn_can_interrupt() { + let mut app = App::default(); + app.start_turn("turn-1".to_string()); + + assert!(should_interrupt_turn(&app)); + } + + #[test] + fn vim_search_suppresses_interrupt() { + let mut app = App::default(); + app.start_turn("turn-1".to_string()); + app.composer.set_vim_enabled(true); + app.composer + .handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert!(!should_interrupt_turn(&app)); + } +} diff --git a/lime-rs/crates/tui/src/app/pending_interactive_replay.rs b/lime-rs/crates/tui/src/app/pending_interactive_replay.rs index 0cc555fc3..c6f2d2f94 100644 --- a/lime-rs/crates/tui/src/app/pending_interactive_replay.rs +++ b/lime-rs/crates/tui/src/app/pending_interactive_replay.rs @@ -483,6 +483,7 @@ mod tests { turn_id: turn_id.to_string(), item_id: item_id.to_string(), questions: Vec::new(), + is_blocking: true, auto_resolution_ms: None, }, } diff --git a/lime-rs/crates/tui/src/app/replay_filter.rs b/lime-rs/crates/tui/src/app/replay_filter.rs index bf443cd82..bb5975bef 100644 --- a/lime-rs/crates/tui/src/app/replay_filter.rs +++ b/lime-rs/crates/tui/src/app/replay_filter.rs @@ -127,6 +127,7 @@ mod tests { turn_id: "turn".to_string(), item_id: "item".to_string(), questions: Vec::new(), + is_blocking: true, auto_resolution_ms: None, }, }; diff --git a/lime-rs/crates/tui/src/app/tests.rs b/lime-rs/crates/tui/src/app/tests.rs index e652b8563..30566d483 100644 --- a/lime-rs/crates/tui/src/app/tests.rs +++ b/lime-rs/crates/tui/src/app/tests.rs @@ -1,8 +1,9 @@ use super::*; -use crate::command_popup::CommandPopup; +use crate::bottom_pane::command_popup::CommandPopup; use app_server_protocol::protocol::v2::{ CommandExecutionApprovalDecision, CommandExecutionRequestApprovalParams, McpServerStartupState, - McpServerStatusUpdatedNotification, ServerNotification, ServerRequest, UserInput, + McpServerStatusDetail, McpServerStatusUpdatedNotification, ServerNotification, ServerRequest, + UserInput, }; use app_server_protocol::RequestId; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -266,6 +267,43 @@ fn slash_pwd_and_cwd_alias_display_current_working_directory_from_composer() { ); } +#[test] +fn mcp_slash_commands_request_the_matching_inventory_detail() { + for (command, detail) in [ + ("/mcp", McpServerStatusDetail::ToolsAndAuthOnly), + ("/mcp verbose", McpServerStatusDetail::Full), + ] { + let mut app = App::default(); + app.replace_composer(command.to_string()); + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ), + AppAction::FetchMcpInventory { detail } + ); + assert!(app.composer.is_empty()); + assert!(app.composer.command_popup().is_none()); + } +} + +#[test] +fn mcp_slash_command_rejects_unknown_arguments_with_localized_usage() { + let mut app = App::default(); + app.set_locale(Locale::ZhCn); + app.replace_composer("/mcp compact".to_string()); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)), + ), + AppAction::None + ); + assert_eq!(app.projection.status(), "用法:/mcp [verbose]"); + assert!(app.composer.is_empty()); +} + #[test] fn tab_queues_a_follow_up_without_submitting_the_active_turn() { let mut app = App::default(); @@ -922,6 +960,40 @@ fn ctrl_t_opens_transcript_without_copying_or_mutating_conversation_state() { assert_eq!(app.composer.text(), "draft"); } +#[test] +fn transcript_overlay_requests_older_history_only_when_session_has_more_pages() { + let mut app = App { + scrollback_has_older_history: true, + ..App::default() + }; + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL,)) + ), + AppAction::None + ); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE,)) + ), + AppAction::LoadOlderHistory + ); +} + +#[test] +fn switching_threads_resets_the_main_transcript_to_the_tail() { + let mut app = App::default(); + app.set_thread_id("thread-1".to_string()); + app.transcript_scroll = 12; + + app.set_thread_id("thread-2".to_string()); + + assert_eq!(app.transcript_scroll, 0); +} + #[test] fn image_shortcut_attaches_and_allows_image_only_submission() { let mut app = App::default(); @@ -1270,6 +1342,38 @@ fn agents_slash_command_opens_the_agents_overview() { assert!(app.composer.is_empty()); } +#[test] +fn empty_composer_left_opens_agents_overview_when_local_navigation_is_enabled() { + let mut app = App::default(); + app.composer.set_agents_navigation_enabled(true); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ), + AppAction::RefreshAgentsOverview + ); + assert!(app.agents_overview.is_some()); + assert!(app.composer.is_empty()); +} + +#[test] +fn empty_composer_left_stays_in_editor_when_navigation_is_disabled() { + let mut app = App::default(); + app.composer.set_agents_navigation_enabled(false); + + assert_eq!( + dispatch_connected_input( + &mut app, + Event::Key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)), + ), + AppAction::None + ); + assert!(app.agents_overview.is_none()); + assert!(app.composer.is_empty()); +} + #[test] fn resume_slash_command_opens_the_shared_picker_action() { let mut app = App::default(); 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 index c2f4330b9..67c61735b 100644 --- a/lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs +++ b/lime-rs/crates/tui/src/app/thread_event_buffer_tests.rs @@ -71,6 +71,7 @@ fn user_input_request(id: i64, thread_id: &str) -> ServerRequest { turn_id: "turn".to_string(), item_id: format!("item-{id}"), questions: Vec::new(), + is_blocking: true, auto_resolution_ms: None, }, } diff --git a/lime-rs/crates/tui/src/app/thread_events.rs b/lime-rs/crates/tui/src/app/thread_events.rs index 117c2fa0d..2cf337f86 100644 --- a/lime-rs/crates/tui/src/app/thread_events.rs +++ b/lime-rs/crates/tui/src/app/thread_events.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; use std::collections::VecDeque; use std::time::Instant; -use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem}; +use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest}; use super::app_server_event_targets::{ server_notification_thread_target, ServerNotificationThreadTarget, @@ -216,9 +216,11 @@ impl App { 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()); - } + self.turn_lifecycle.sync_projection_turn( + previous_turn_id.as_deref(), + active_turn_id, + Instant::now(), + ); } pub(super) fn note_outbound_response( @@ -265,33 +267,12 @@ impl App { app_server_protocol::protocol::v2::ThreadStatus::NotLoaded => {} }, ServerNotification::ItemStarted(params) => { - self.observe_item(¶ms.item); + super::tool_lifecycle::observe_item(self, ¶ms.item); } ServerNotification::ItemCompleted(params) => { - self.observe_item(¶ms.item); + super::tool_lifecycle::observe_item(self, ¶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/tool_lifecycle.rs b/lime-rs/crates/tui/src/app/tool_lifecycle.rs new file mode 100644 index 000000000..a8c639e30 --- /dev/null +++ b/lime-rs/crates/tui/src/app/tool_lifecycle.rs @@ -0,0 +1,77 @@ +//! Tool lifecycle observations for the TUI app. +//! +//! Canonical tool state remains owned by App Server `ThreadItem` projection. This module only +//! forwards lifecycle facts that affect the TUI's agent navigation affordances. + +use app_server_protocol::protocol::v2::{CollabAgentTool, ThreadItem}; + +use super::App; + +/// Observe a canonical tool item without creating a second tool or thread store. +pub(super) fn observe_item(app: &mut App, item: &ThreadItem) { + if let Some(activity) = crate::multi_agents::sub_agent_activity_display(item) { + // Parent-stream activity is authoritative until the child is resumed independently. + app.agent_navigation + .mark_parent_owned(activity.thread_id.clone()); + app.agent_navigation.record_sub_agent_activity(activity); + return; + } + + if let ThreadItem::CollabAgentToolCall { + tool: CollabAgentTool::SpawnAgent, + receiver_thread_ids, + .. + } = item + { + for thread_id in receiver_thread_ids { + app.agent_navigation.mark_parent_owned(thread_id.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::{CollabAgentToolCallStatus, SubAgentActivityKind}; + + #[test] + fn spawn_marks_receiver_thread_as_parent_owned() { + let mut app = App::default(); + let item = ThreadItem::CollabAgentToolCall { + id: "spawn-1".to_string(), + metadata: None, + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::InProgress, + sender_thread_id: "parent-1".to_string(), + receiver_thread_ids: vec!["child-1".to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: std::collections::HashMap::new(), + }; + + observe_item(&mut app, &item); + + assert!(app.agent_navigation.is_parent_owned("child-1")); + } + + #[test] + fn sub_agent_activity_updates_navigation_liveness() { + let mut app = App::default(); + let item = ThreadItem::SubAgentActivity { + id: "activity-1".to_string(), + metadata: None, + kind: SubAgentActivityKind::Started, + agent_thread_id: "child-1".to_string(), + agent_path: "/root/child".to_string(), + }; + + observe_item(&mut app, &item); + + assert!(app.agent_navigation.is_parent_owned("child-1")); + assert!(app + .agent_navigation + .get("child-1") + .is_some_and(|entry| entry.is_running)); + } +} diff --git a/lime-rs/crates/tui/src/app/turn_lifecycle.rs b/lime-rs/crates/tui/src/app/turn_lifecycle.rs new file mode 100644 index 000000000..b64dcb9ca --- /dev/null +++ b/lime-rs/crates/tui/src/app/turn_lifecycle.rs @@ -0,0 +1,133 @@ +//! Agent turn lifecycle state for the TUI application. +//! +//! This owner mirrors Codex's turn lifecycle bookkeeping while keeping transport and canonical +//! Thread/Turn/Item projection ownership in the existing App Server path. + +use std::collections::HashSet; +use std::time::{Duration, Instant}; + +#[derive(Debug, Default)] +pub(super) struct TurnLifecycleState { + /// Tracks whether the canonical projection currently has an active agent turn. + pub(super) agent_turn_running: bool, + pub(super) last_turn_id: Option, + pub(super) budget_limited_turn_ids: HashSet, + /// Completion labels already inserted into this thread's visible history. + #[allow(dead_code)] + pub(super) rendered_completion_turn_ids: HashSet, + pub(super) active_turn_started_at: Option, +} + +impl TurnLifecycleState { + pub(super) fn start(&mut self, turn_id: String, now: Instant) { + self.agent_turn_running = true; + self.last_turn_id = Some(turn_id); + self.active_turn_started_at = Some(now); + } + + pub(super) fn finish(&mut self) { + self.agent_turn_running = false; + self.active_turn_started_at = None; + } + + pub(super) fn restore_running(&mut self, turn_id: Option<&str>, now: Instant) { + self.last_turn_id = turn_id.map(str::to_owned); + self.agent_turn_running = turn_id.is_some(); + self.active_turn_started_at = turn_id.map(|_| now); + } + + pub(super) fn sync_projection_turn( + &mut self, + previous_turn_id: Option<&str>, + active_turn_id: Option<&str>, + now: Instant, + ) { + if active_turn_id == previous_turn_id { + return; + } + match active_turn_id { + Some(turn_id) => self.start(turn_id.to_owned(), now), + None => self.finish(), + } + } + + pub(super) fn reset_thread(&mut self) { + self.finish(); + self.last_turn_id = None; + self.budget_limited_turn_ids.clear(); + self.rendered_completion_turn_ids.clear(); + } + + pub(super) fn elapsed(&self, active_turn_id: Option<&str>, now: Instant) -> Option { + active_turn_id?; + Some( + self.active_turn_started_at + .map(|started_at| now.saturating_duration_since(started_at)) + .unwrap_or_default(), + ) + } + + #[allow(dead_code)] + pub(super) fn mark_budget_limited(&mut self, turn_id: String) { + self.budget_limited_turn_ids.insert(turn_id); + } + + #[allow(dead_code)] + pub(super) fn take_budget_limited(&mut self, turn_id: &str) -> bool { + self.budget_limited_turn_ids.remove(turn_id) + } +} + +impl super::App { + pub(crate) fn start_turn(&mut self, turn_id: String) { + self.projection.start_turn(turn_id.clone()); + self.turn_lifecycle.start(turn_id, Instant::now()); + } + + pub(crate) fn active_turn_elapsed(&self, now: Instant) -> Option { + self.turn_lifecycle + .elapsed(self.projection.active_turn_id(), now) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn start_and_finish_update_running_state() { + let now = Instant::now(); + let mut state = TurnLifecycleState::default(); + + state.start("turn-1".to_string(), now); + assert!(state.agent_turn_running); + assert_eq!(state.last_turn_id.as_deref(), Some("turn-1")); + assert!(state.active_turn_started_at.is_some()); + + state.finish(); + assert!(!state.agent_turn_running); + assert!(state.active_turn_started_at.is_none()); + } + + #[test] + fn projection_transition_starts_and_finishes_timer() { + let now = Instant::now(); + let mut state = TurnLifecycleState::default(); + + state.sync_projection_turn(None, Some("turn-1"), now); + assert!(state.elapsed(Some("turn-1"), now).is_some()); + + state.sync_projection_turn(Some("turn-1"), None, now); + assert!(state.elapsed(None, now).is_none()); + } + + #[test] + fn budget_limited_turn_ids_are_consumed() { + let mut state = TurnLifecycleState::default(); + + state.mark_budget_limited("turn-1".to_string()); + + assert!(state.take_budget_limited("turn-1")); + assert!(!state.take_budget_limited("turn-1")); + } +} diff --git a/lime-rs/crates/tui/src/app_server_session.rs b/lime-rs/crates/tui/src/app_server_session.rs index ae9edbf64..bdbf2f81b 100644 --- a/lime-rs/crates/tui/src/app_server_session.rs +++ b/lime-rs/crates/tui/src/app_server_session.rs @@ -13,7 +13,8 @@ use app_server_client::{ use app_server_protocol::protocol::v2::{ CollaborationModeListParams, CollaborationModeListResponse, CollaborationModeMask, CurrentTimeReadResponse, FuzzyFileSearchParams, FuzzyFileSearchResponse, - McpServerElicitationRequestResponse, ModelListParams, ModelListResponse, + ListMcpServerStatusParams, ListMcpServerStatusResponse, McpServerElicitationRequestResponse, + McpServerStatus, McpServerStatusDetail, ModelListParams, ModelListResponse, PermissionProfileListParams, PermissionProfileListResponse, PromptHistoryAppendParams, PromptHistoryAppendResponse, PromptHistoryReadParams, PromptHistoryReadResponse, QueuedSubmission, ServerRequest, SkillsListParams, SkillsListResponse, ThreadForkParams, @@ -25,9 +26,10 @@ use app_server_protocol::protocol::v2::{ ThreadStartResponse, ThreadStartSource, ThreadUnarchiveParams, ThreadUnarchiveResponse, TurnInterruptParams, TurnInterruptResponse, TurnStartParams, TurnStartResponse, TurnSteerParams, TurnSteerResponse, UserInput, METHOD_COLLABORATION_MODE_LIST, - METHOD_FUZZY_FILE_SEARCH, METHOD_PERMISSION_PROFILE_LIST, METHOD_PROMPT_HISTORY_APPEND, - METHOD_PROMPT_HISTORY_READ, METHOD_SKILLS_LIST, METHOD_THREAD_ARCHIVE, METHOD_THREAD_QUEUE_ADD, - METHOD_THREAD_QUEUE_DELETE, METHOD_THREAD_QUEUE_LIST, METHOD_THREAD_READ, METHOD_THREAD_RESUME, + METHOD_FUZZY_FILE_SEARCH, METHOD_MCP_SERVER_STATUS_LIST, METHOD_PERMISSION_PROFILE_LIST, + METHOD_PROMPT_HISTORY_APPEND, METHOD_PROMPT_HISTORY_READ, METHOD_SKILLS_LIST, + 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, }; @@ -57,6 +59,39 @@ pub(crate) struct AppServerSession { } impl AppServerSession { + pub(crate) async fn list_mcp_server_statuses( + &self, + detail: McpServerStatusDetail, + ) -> Result> { + let mut cursor = None; + let mut seen_cursors = HashSet::new(); + let mut data = Vec::new(); + for _ in 0..16 { + let page: ListMcpServerStatusResponse = self + .request_handle + .request( + METHOD_MCP_SERVER_STATUS_LIST, + ListMcpServerStatusParams { + cursor, + limit: Some(64), + detail: Some(detail), + thread_id: self.thread_id.clone(), + }, + ) + .await + .context("failed to list App Server MCP server statuses")?; + data.extend(page.data); + let Some(next_cursor) = page.next_cursor else { + return Ok(data); + }; + if !seen_cursors.insert(next_cursor.clone()) { + bail!("MCP server status list pagination repeated cursor {next_cursor}"); + } + cursor = Some(next_cursor); + } + bail!("MCP server status list pagination exceeded 16 pages") + } + /// Search files through the current App Server contract using a cloned request boundary. /// All interactive queries share one cancellation token so newer queries supersede older /// filesystem walks without creating another transport or runtime owner. diff --git a/lime-rs/crates/tui/src/app_server_session/history.rs b/lime-rs/crates/tui/src/app_server_session/history.rs index c428269e5..7731185f6 100644 --- a/lime-rs/crates/tui/src/app_server_session/history.rs +++ b/lime-rs/crates/tui/src/app_server_session/history.rs @@ -214,7 +214,10 @@ impl AppServerSession { return Ok(chronological_turns(turns_desc)); } let Some(next_cursor) = page.next_cursor else { - return Ok(chronological_turns(turns_desc)); + if all_target_turns_loaded(&turns_desc, target_turn_ids) { + return Ok(chronological_turns(turns_desc)); + } + bail!("thread turns page is missing an item page target turn"); }; if !seen_cursors.insert(next_cursor.clone()) { bail!("thread turns pagination repeated cursor {next_cursor}"); diff --git a/lime-rs/crates/tui/src/app_server_session/history_tests.rs b/lime-rs/crates/tui/src/app_server_session/history_tests.rs index 8187e1e7a..19dea4b66 100644 --- a/lime-rs/crates/tui/src/app_server_session/history_tests.rs +++ b/lime-rs/crates/tui/src/app_server_session/history_tests.rs @@ -138,3 +138,10 @@ fn turn_lookup_returns_chronological_order_for_history_filtering() { .collect::>(); assert_eq!(ids, vec!["oldest", "older", "newest"]); } + +#[test] +fn turn_lookup_does_not_treat_an_unrelated_turn_as_the_item_page_target() { + let turns_desc = vec![test_turn("unrelated")]; + let targets = std::collections::HashSet::from([String::from("missing")]); + assert!(!all_target_turns_loaded(&turns_desc, &targets)); +} 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 ff92d2c50..fc5cd344c 100644 --- a/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs +++ b/lime-rs/crates/tui/src/bottom_pane/approval_overlay.rs @@ -63,12 +63,22 @@ impl ApprovalOverlay { return Some(self.cancel_response()); } match key.code { - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); + KeyCode::Up | KeyCode::Char('p') | KeyCode::Char('k') + if key.code == KeyCode::Up || key.modifiers.contains(KeyModifiers::CONTROL) => + { + let count = self.option_count(); + if count > 0 { + self.selected = self.selected.checked_sub(1).unwrap_or(count - 1); + } None } - KeyCode::Down | KeyCode::Char('j') => { - self.selected = (self.selected + 1).min(self.option_count().saturating_sub(1)); + KeyCode::Down | KeyCode::Char('n') | KeyCode::Char('j') + if key.code == KeyCode::Down || key.modifiers.contains(KeyModifiers::CONTROL) => + { + let count = self.option_count(); + if count > 0 { + self.selected = (self.selected + 1) % count; + } None } KeyCode::Enter => Some(self.response_for_selected()), @@ -77,7 +87,7 @@ impl ApprovalOverlay { self.selected = 0; Some(self.response_for_selected()) } - KeyCode::Char('n') => Some(self.decline_response()), + KeyCode::Char('n') if key.modifiers.is_empty() => Some(self.decline_response()), _ => None, } } @@ -371,6 +381,17 @@ mod tests { )); } + #[test] + fn approval_navigation_wraps_and_accepts_control_bindings() { + let mut approval = command_approval(); + approval.handle_key_event(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL)); + assert_eq!(approval.selected, approval.option_count() - 1); + approval.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + assert_eq!(approval.selected, 0); + approval.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(approval.selected, approval.option_count() - 1); + } + #[test] fn n_declines_exec_decision() { let mut approval = command_approval(); 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 c0e6dcbd1..d71440e87 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer.rs @@ -5,7 +5,9 @@ use std::cell::RefMut; use std::ops::Range; use unicode_segmentation::UnicodeSegmentation; +mod agents_navigation; mod attachment_state; +mod completion_target; mod draft_state; mod file_search_popup; mod footer_state; @@ -27,8 +29,8 @@ use self::popup_state::{ActivePopup, DismissedToken, PopupState}; use self::skill_popup::SkillPopup; pub(crate) use self::skill_popup::SkillPopupAction; use self::vim_history::VimHistory; +use super::command_popup::{CommandPopup, CommandPopupAction}; use crate::bottom_pane::textarea::{TextArea, TextAreaState}; -use crate::command_popup::{CommandPopup, CommandPopupAction}; use app_server_protocol::protocol::v2::{FuzzyFileSearchResult, SkillMetadata}; const MAX_HISTORY_ENTRIES: usize = 200; @@ -51,6 +53,7 @@ pub(crate) enum InputResult { PreviousPermissions, NextPermissions, OpenExternalEditor, + OpenAgentsOverview, Quit, } @@ -67,10 +70,8 @@ pub(crate) struct ChatComposer { vim_history: VimHistory, file_search_generation: u64, file_search_request: Option, - file_search_requested_query: Option, - dismissed_file_token: Option, skills: Vec, - dismissed_skill_token: Option, + agents_navigation_enabled: bool, } impl ChatComposer { @@ -401,7 +402,7 @@ impl ChatComposer { match action { SkillPopupAction::Cancel => { if let Some((range, query)) = self.current_skill_token_range() { - self.dismissed_skill_token = + self.popups.dismissed_skill_token = Some(DismissedToken::new(self.text(), range, query)); } self.popups.active = ActivePopup::None; @@ -423,7 +424,7 @@ impl ChatComposer { let inserted_range = start..start.saturating_add(inserted.len()); self.draft.textarea.set_cursor(inserted_range.end); self.advance_past_file_completion_separator(); - self.dismissed_skill_token = + self.popups.dismissed_skill_token = Some(DismissedToken::new(self.text(), inserted_range, name)); self.reset_history_navigation(); if started { @@ -464,7 +465,7 @@ impl ChatComposer { match action { FileSearchPopupAction::Cancel => { if let Some((range, query)) = self.current_at_token_range() { - self.dismissed_file_token = + self.popups.dismissed_file_token = Some(DismissedToken::new(self.text(), range, query)); } self.popups.active = ActivePopup::None; @@ -483,7 +484,7 @@ impl ChatComposer { .textarea .set_cursor(start.saturating_add(path.len())); self.advance_past_file_completion_separator(); - self.dismissed_file_token = None; + self.popups.dismissed_file_token = None; self.popups.active = ActivePopup::None; self.sync_command_popup(); self.reset_history_navigation(); @@ -536,13 +537,14 @@ impl ChatComposer { if self.history_index.is_some() { self.popups.active = ActivePopup::None; self.file_search_request = None; - self.file_search_requested_query = None; + self.popups.file_search_requested_query = None; return; } if !self.skills.is_empty() { if let Some((range, query)) = self.current_skill_token_range() { if skill_query_is_candidate(&query, &self.skills) { if self + .popups .dismissed_skill_token .as_ref() .is_some_and(|dismissed| dismissed.matches(self.text(), &range, &query)) @@ -552,7 +554,7 @@ impl ChatComposer { } return; } - self.dismissed_skill_token = None; + self.popups.dismissed_skill_token = None; match &mut self.popups.active { ActivePopup::Skill(popup) => { popup.set_query(query.clone()); @@ -567,12 +569,13 @@ impl ChatComposer { } } } - self.dismissed_skill_token = None; + self.popups.dismissed_skill_token = None; if matches!(self.popups.active, ActivePopup::Skill(_)) { self.popups.active = ActivePopup::None; } if let Some((range, query)) = self.current_at_token_range() { if self + .popups .dismissed_file_token .as_ref() .is_some_and(|dismissed| dismissed.matches(self.text(), &range, &query)) @@ -582,7 +585,7 @@ impl ChatComposer { } return; } - self.dismissed_file_token = None; + self.popups.dismissed_file_token = None; match &mut self.popups.active { ActivePopup::File(popup) => popup.set_query(query.clone()), ActivePopup::Command(_) | ActivePopup::Skill(_) | ActivePopup::None => { @@ -594,7 +597,7 @@ impl ChatComposer { popup.set_empty_prompt(); } self.file_search_request = None; - self.file_search_requested_query = None; + self.popups.file_search_requested_query = None; return; } let current_query = match &self.popups.active { @@ -603,24 +606,24 @@ impl ChatComposer { }; if let Some(current_query) = current_query { if !current_query.is_empty() - && self.file_search_requested_query.as_deref() != Some(current_query) + && self.popups.file_search_requested_query.as_deref() != Some(current_query) { self.file_search_generation = self.file_search_generation.wrapping_add(1); self.file_search_request = Some(FileSearchRequest { generation: self.file_search_generation, query: current_query.to_string(), }); - self.file_search_requested_query = Some(current_query.to_string()); + self.popups.file_search_requested_query = Some(current_query.to_string()); } } return; } - self.dismissed_file_token = None; + self.popups.dismissed_file_token = None; if matches!(self.popups.active, ActivePopup::File(_)) { self.popups.active = ActivePopup::None; self.file_search_request = None; } - self.file_search_requested_query = None; + self.popups.file_search_requested_query = None; let text = self.text().to_string(); let first_line = text.lines().next().unwrap_or(""); let Some(filter) = slash_input::command_popup_filter_text(first_line, self.cursor()) else { @@ -646,11 +649,11 @@ impl ChatComposer { } fn current_at_token_range(&self) -> Option<(Range, String)> { - current_at_token_range(self.text(), self.cursor()) + completion_target::current_prefixed_token_range(&self.draft.textarea, '@', false) } fn current_skill_token_range(&self) -> Option<(Range, String)> { - current_dollar_token_range(self.text(), self.cursor()) + completion_target::current_prefixed_token_range(&self.draft.textarea, '$', true) } /// Leave the cursor after one horizontal separator following a file completion. @@ -739,6 +742,13 @@ impl ChatComposer { return InputResult::Changed; } + if key.code == KeyCode::Left + && key.modifiers.is_empty() + && self.agents_navigation_available() + { + return InputResult::OpenAgentsOverview; + } + if key.modifiers.contains(KeyModifiers::CONTROL) { return match key.code { KeyCode::Char('c') => InputResult::Interrupt, @@ -1049,122 +1059,36 @@ impl ChatComposer { } } -/// Find the editable `@token` containing the cursor. The range includes `@` and -/// uses byte offsets so it can be passed directly to `TextArea::replace_range`. -fn current_at_token_range(text: &str, cursor: usize) -> Option<(Range, String)> { - let cursor = cursor.min(text.len()); - let cursor = if text.is_char_boundary(cursor) { - cursor - } else { - text.char_indices() - .map(|(index, _)| index) - .take_while(|index| *index < cursor) - .last() - .unwrap_or(0) - }; - let left = &text[..cursor]; - let start = left - .char_indices() - .rev() - .find(|(_, ch)| ch.is_whitespace()) - .map(|(index, ch)| index + ch.len_utf8()) - .unwrap_or(0); - let segment_end = text[cursor..] - .char_indices() - .find(|(_, ch)| ch.is_whitespace()) - .map(|(index, _)| cursor + index) - .unwrap_or(text.len()); - let segment = &text[start..segment_end]; - let at = segment.find('@')?; - if at > 0 { - return None; - } - let range = start..segment_end; - Some((range, segment[1..].to_string())) -} - -/// Find the editable `$name` token containing the cursor. The range includes `$` and uses byte -/// offsets so completion can replace exactly one token without touching adjacent text. -fn current_dollar_token_range(text: &str, cursor: usize) -> Option<(Range, String)> { - let cursor = cursor.min(text.len()); - let cursor = if text.is_char_boundary(cursor) { - cursor - } else { - text.char_indices() - .map(|(index, _)| index) - .take_while(|index| *index < cursor) - .last() - .unwrap_or(0) - }; - let left = &text[..cursor]; - let start = left - .char_indices() - .rev() - .find(|(_, ch)| ch.is_whitespace()) - .map(|(index, ch)| index + ch.len_utf8()) - .unwrap_or(0); - let end = text[cursor..] - .char_indices() - .find(|(_, ch)| ch.is_whitespace()) - .map(|(index, _)| cursor + index) - .unwrap_or(text.len()); - let token = &text[start..end]; - let query = token.strip_prefix('$')?; - if query - .chars() - .all(|character| character.is_alphanumeric() || matches!(character, '_' | '-' | ':')) - { - Some((start..end, query.to_string())) - } else { - None - } -} - fn skill_query_is_candidate(query: &str, skills: &[SkillMetadata]) -> bool { - if query.is_empty() { - return true; + match completion_target::dollar_query_kind(query) { + completion_target::DollarQueryKind::Completable => true, + completion_target::DollarQueryKind::AmbiguousShellParameter => { + skills.iter().any(|skill| skill.name == query) + } + completion_target::DollarQueryKind::ShellVariable + | completion_target::DollarQueryKind::DefiniteShellParameter + | completion_target::DollarQueryKind::Invalid => false, } - if is_common_shell_variable(query) - || query == "-" - || query == "_" - || query.chars().all(|character| character.is_ascii_digit()) - { - return false; - } - if query - .chars() - .next() - .is_some_and(|character| character.is_ascii_digit() || character == '-') - { - return skills.iter().any(|skill| skill.name == query); - } - true -} - -fn is_common_shell_variable(query: &str) -> bool { - query.bytes().all(|byte| !byte.is_ascii_lowercase()) - && matches!( - query, - "HOME" - | "PATH" - | "SHELL" - | "USER" - | "PWD" - | "OLDPWD" - | "TMPDIR" - | "EDITOR" - | "TERM" - | "LANG" - | "RANDOM" - | "SECONDS" - | "SHLVL" - ) } #[cfg(test)] mod tests { use super::*; + fn current_at_token_range(text: &str, cursor: usize) -> Option<(Range, String)> { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(cursor); + completion_target::current_prefixed_token_range(&textarea, '@', false) + } + + fn current_dollar_token_range(text: &str, cursor: usize) -> Option<(Range, String)> { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(cursor); + completion_target::current_prefixed_token_range(&textarea, '$', true) + } + fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) } diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/agents_navigation.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/agents_navigation.rs new file mode 100644 index 000000000..18823b425 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/agents_navigation.rs @@ -0,0 +1,118 @@ +//! 空草稿下的 Agents Overview 导航。 +//! +//! 该 owner 只负责判断 composer 是否可以把 Left 交给 Agents Overview; +//! Overview 的生命周期和 App Server 刷新仍由 `App`/`AgentsOverviewState` 承担。 + +use super::ChatComposer; + +impl ChatComposer { + /// 仅在本地 stdio 会话显式启用空草稿导航。 + pub(crate) fn set_agents_navigation_enabled(&mut self, enabled: bool) { + self.agents_navigation_enabled = enabled; + } + + /// 判断 Left 是否应离开空 composer 并打开 Agents Overview。 + /// + /// 普通文本、附件、popup、历史搜索和 Vim operator pending 都留在各自 + /// 的 composer owner 内处理;默认关闭保证 remote session fail-closed。 + pub(super) fn agents_navigation_available(&self) -> bool { + self.agents_navigation_enabled + && self.is_empty() + && !self.has_pending_images() + && !self.popups.active() + && self.history_search.is_none() + && !self.vim_search_active() + && !self.draft.textarea.is_vim_operator_pending() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + fn left() -> KeyEvent { + KeyEvent::new(KeyCode::Left, KeyModifiers::NONE) + } + + #[test] + fn left_navigation_is_disabled_by_default_and_for_remote_sessions() { + let mut composer = ChatComposer::default(); + assert!(!composer.agents_navigation_available()); + composer.set_agents_navigation_enabled(true); + assert!(composer.agents_navigation_available()); + composer.set_agents_navigation_enabled(false); + assert!(!composer.agents_navigation_available()); + } + + #[test] + fn left_navigation_requires_an_empty_unblocked_composer() { + let mut composer = ChatComposer::default(); + composer.set_agents_navigation_enabled(true); + assert!(composer.agents_navigation_available()); + + composer.insert("draft"); + assert!(!composer.agents_navigation_available()); + composer.clear_for_ctrl_c(); + composer.attach_image(std::path::PathBuf::from("/tmp/image.png")); + assert!(!composer.agents_navigation_available()); + } + + #[test] + fn left_navigation_does_not_steal_popup_or_vim_operator_input() { + let mut composer = ChatComposer::default(); + composer.set_agents_navigation_enabled(true); + composer.insert("/mo"); + composer.sync_command_popup(); + assert!(!composer.agents_navigation_available()); + + composer.clear_for_ctrl_c(); + composer.set_vim_enabled(true); + composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + composer.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); + assert!(!composer.agents_navigation_available()); + } + + #[test] + fn left_navigation_does_not_steal_active_vim_search() { + let mut composer = ChatComposer::default(); + composer.set_agents_navigation_enabled(true); + composer.set_vim_enabled(true); + + composer.handle_key_event(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE)); + + assert!(composer.vim_search_active()); + assert!(!composer.agents_navigation_available()); + assert_ne!( + composer.handle_key_event(left()), + super::super::InputResult::OpenAgentsOverview + ); + } + + #[test] + fn left_key_returns_open_agents_overview_only_when_available() { + let mut composer = ChatComposer::default(); + composer.set_agents_navigation_enabled(true); + assert_eq!( + composer.handle_key_event(left()), + super::super::InputResult::OpenAgentsOverview + ); + + composer.insert("x"); + assert_ne!( + composer.handle_key_event(left()), + super::super::InputResult::OpenAgentsOverview + ); + } + + #[test] + fn modified_left_stays_in_the_editor() { + let mut composer = ChatComposer::default(); + composer.set_agents_navigation_enabled(true); + let modified = KeyEvent::new(KeyCode::Left, KeyModifiers::ALT); + assert_ne!( + composer.handle_key_event(modified), + super::super::InputResult::OpenAgentsOverview + ); + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target.rs new file mode 100644 index 000000000..fb57c80b0 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target.rs @@ -0,0 +1,238 @@ +//! Cursor-neighborhood resolution for sigil-prefixed composer completions. +//! +//! This module is the single owner for `@` file and `$` skill completion targets. It keeps +//! cursor affinity and shell-syntax arbitration independent from popup presentation. + +use crate::bottom_pane::textarea::TextArea; +use std::ops::Range; + +fn is_horizontal_whitespace(ch: char) -> bool { + ch.is_whitespace() + && !matches!( + ch, + '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}' + ) +} + +fn safe_cursor(text: &str, cursor: usize) -> usize { + let cursor = cursor.min(text.len()); + if text.is_char_boundary(cursor) { + return cursor; + } + text.char_indices() + .map(|(index, _)| index) + .take_while(|index| *index < cursor) + .last() + .unwrap_or(0) +} + +fn whitespace_delimited_range( + text: &str, + cursor: usize, +) -> (Range, Range, bool, bool) { + let cursor = safe_cursor(text, cursor); + let before = &text[..cursor]; + let after = &text[cursor..]; + let at_whitespace = after.chars().next().is_some_and(char::is_whitespace); + let after_horizontal = before + .chars() + .next_back() + .is_some_and(is_horizontal_whitespace); + let next_non_separator = after.chars().find(|ch| !is_horizontal_whitespace(*ch)); + let separator_precedes_token = next_non_separator.is_some_and(|ch| !ch.is_whitespace()); + let at_separator = (at_whitespace || after_horizontal) && separator_precedes_token; + + let end_left = if at_separator { + before.trim_end_matches(is_horizontal_whitespace).len() + } else { + let end_rel = after + .char_indices() + .find(|(_, ch)| ch.is_whitespace()) + .map(|(index, _)| index) + .unwrap_or(after.len()); + cursor + end_rel + }; + let start_left = text[..end_left] + .char_indices() + .rfind(|(_, ch)| ch.is_whitespace()) + .map(|(index, ch)| index + ch.len_utf8()) + .unwrap_or(0); + + let right_start = cursor + + after + .chars() + .take_while(|ch| is_horizontal_whitespace(*ch)) + .map(char::len_utf8) + .sum::(); + let right_end = right_start + + text[right_start..] + .char_indices() + .find(|(_, ch)| ch.is_whitespace()) + .map(|(index, _)| index) + .unwrap_or(text.len().saturating_sub(right_start)); + + ( + start_left..end_left, + right_start..right_end, + at_separator, + after_horizontal, + ) +} + +fn prefixed_candidate( + text: &str, + range: Range, + prefix: char, + allow_empty: bool, +) -> Option<(Range, String)> { + let token = text.get(range.clone())?; + let query = token.strip_prefix(prefix)?; + if !allow_empty && query.is_empty() { + return None; + } + Some((range, query.to_string())) +} + +/// Extracts the prefixed completion target nearest to the cursor. +/// +/// Horizontal whitespace has same-line affinity; line breaks are hard boundaries. At a separator +/// the right target wins when the cursor starts a new token, while a left shell-like `$` target +/// yields to a completable right target. Returned ranges include the sigil. +pub(super) fn current_prefixed_token_range( + textarea: &TextArea, + prefix: char, + allow_empty: bool, +) -> Option<(Range, String)> { + current_prefixed_token_range_with_dollar_predicate( + textarea, + prefix, + allow_empty, + dollar_query_is_completable, + ) +} + +pub(super) fn current_prefixed_token_range_with_dollar_predicate( + textarea: &TextArea, + prefix: char, + allow_empty: bool, + dollar_query_is_completable: impl Fn(&str) -> bool, +) -> Option<(Range, String)> { + let text = textarea.text(); + let cursor = safe_cursor(text, textarea.cursor()); + let before = &text[..cursor]; + let after = &text[cursor..]; + let (left_range, right_range, at_separator, after_horizontal) = + whitespace_delimited_range(text, cursor); + let left = prefixed_candidate(text, left_range.clone(), prefix, allow_empty); + let right = prefixed_candidate(text, right_range.clone(), prefix, allow_empty); + let right_token = text.get(right_range.clone()); + let cursor_starts_token = + after.chars().next().is_some_and(|ch| !ch.is_whitespace()) && after_horizontal; + let separator_precedes_completion = right_token + .and_then(|token| token.chars().next()) + .is_some_and(|ch| matches!(ch, '$' | '@')); + + if cursor_starts_token { + return right; + } + + if allow_empty && after.starts_with(prefix) { + let left_fragment = &text[left_range.start..cursor]; + if let Some(query) = left_fragment.strip_prefix(prefix) { + if query.bytes().all(is_mention_name_char) { + return Some((left_range.start..cursor, query.to_string())); + } + } + } + + if at_separator { + if after_horizontal && !separator_precedes_completion { + return right; + } + if prefix == '$' + && left + .as_ref() + .is_some_and(|(_, query)| !dollar_query_is_completable(query)) + && right + .as_ref() + .is_some_and(|(_, query)| dollar_query_is_completable(query)) + { + return right; + } + return left.or(right); + } + + if after.starts_with(prefix) { + let starts_token = before.chars().next_back().is_none_or(char::is_whitespace); + return if starts_token { right.or(left) } else { left }; + } + left.or(right) +} + +pub(super) fn dollar_query_is_completable(query: &str) -> bool { + matches!(dollar_query_kind(query), DollarQueryKind::Completable) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum DollarQueryKind { + Completable, + ShellVariable, + DefiniteShellParameter, + AmbiguousShellParameter, + Invalid, +} + +pub(super) fn dollar_query_kind(query: &str) -> DollarQueryKind { + let name_end = query + .as_bytes() + .iter() + .take_while(|byte| is_mention_name_char(**byte) || **byte == b':') + .count(); + let name = &query[..name_end]; + let is_shell_var = + name.bytes().all(|byte| !byte.is_ascii_lowercase()) && is_common_env_var(name); + let starts_shell_parameter = name + .as_bytes() + .first() + .is_some_and(|byte| *byte == b'-' || byte.is_ascii_digit()); + let numeric = !name.is_empty() && name.bytes().all(|byte| byte.is_ascii_digit()); + if query.is_empty() { + DollarQueryKind::Completable + } else if name_end == 0 { + DollarQueryKind::Invalid + } else if is_shell_var { + DollarQueryKind::ShellVariable + } else if numeric || matches!(name, "-" | "_") { + DollarQueryKind::DefiniteShellParameter + } else if starts_shell_parameter { + DollarQueryKind::AmbiguousShellParameter + } else { + DollarQueryKind::Completable + } +} + +pub(super) fn is_mention_name_char(byte: u8) -> bool { + matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-') +} + +fn is_common_env_var(name: &str) -> bool { + matches!( + name, + "PATH" + | "HOME" + | "USER" + | "SHELL" + | "PWD" + | "OLDPWD" + | "TMPDIR" + | "TEMP" + | "TMP" + | "LANG" + | "TERM" + | "XDG_CONFIG_HOME" + ) +} + +#[cfg(test)] +#[path = "completion_target_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target_tests.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target_tests.rs new file mode 100644 index 000000000..edcfbc88f --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/completion_target_tests.rs @@ -0,0 +1,164 @@ +use super::*; +use crate::bottom_pane::textarea::TextArea; +use pretty_assertions::assert_eq; + +fn textarea(text: &str, cursor: usize) -> TextArea { + let mut textarea = TextArea::new(); + textarea.insert_str(text); + textarea.set_cursor(cursor); + textarea +} + +#[test] +fn current_prefixed_token_affinity_does_not_cross_line_break() { + for (text, prefix, allow_empty) in [ + ("@file\n continue", '@', false), + ("continue \n@file", '@', false), + ("$skill\n continue", '$', true), + ("continue \n$skill", '$', true), + ] { + let cursor = text.find(" ").expect("indentation present") + 1; + let textarea = textarea(text, cursor); + assert_eq!( + current_prefixed_token_range(&textarea, prefix, allow_empty), + None + ); + } +} + +#[test] +fn current_prefixed_token_affinity_does_not_cross_separator_before_plain_text() { + for (text, prefix, allow_empty) in [("@old word", '@', false), ("$old word", '$', true)] { + let cursor = text.find(" ").expect("separator present") + 1; + let textarea = textarea(text, cursor); + assert_eq!( + current_prefixed_token_range(&textarea, prefix, allow_empty), + None + ); + } +} + +#[test] +fn current_prefixed_token_prefers_right_at_token_start() { + let text = "$old $new"; + let right_start = "$old ".len(); + let textarea = textarea(text, right_start); + assert_eq!( + current_prefixed_token_range(&textarea, '$', true), + Some((right_start..text.len(), "new".to_string())) + ); +} + +#[test] +fn current_prefixed_token_preserves_separator_affinity_between_sigil_targets() { + let text = "$old $new"; + let separator_start = "$old".len(); + let at_separator = textarea(text, separator_start + 1); + assert_eq!( + current_prefixed_token_range(&at_separator, '$', true), + Some((0.."$old".len(), "old".to_string())) + ); + let at_right_start = textarea(text, "$old ".len()); + assert_eq!( + current_prefixed_token_range(&at_right_start, '$', true), + Some(("$old ".len()..text.len(), "new".to_string())) + ); +} + +#[test] +fn current_prefixed_token_handles_cursor_inside_utf8_codepoint_fail_closed() { + let text = "@界"; + let cursor = "@".len() + 1; + assert!(!text.is_char_boundary(cursor)); + assert_eq!( + current_prefixed_token_range(&textarea(text, cursor), '@', false), + Some((0..text.len(), "界".to_string())) + ); +} + +#[test] +fn current_prefixed_token_does_not_cross_trailing_horizontal_whitespace_into_plain_text() { + let text = "$figma next"; + let cursor = "$figma ".len(); + assert_eq!( + current_prefixed_token_range(&textarea(text, cursor), '$', true), + None + ); +} + +#[test] +fn current_prefixed_token_keeps_nested_dollar_prefix_in_same_token() { + let text = "$HOME/$USER"; + let textarea = textarea(text, text.find("$USER").expect("nested prefix present")); + assert_eq!( + current_prefixed_token_range(&textarea, '$', true), + Some((0..text.len(), "HOME/$USER".to_string())) + ); +} + +#[test] +fn current_prefixed_token_accepts_utf8_cursor_boundaries() { + let text = "前 @界🙂 后"; + let start = text.find('@').expect("mention prefix"); + let end = text.find(" 后").expect("trailing separator"); + let expected = Some((start..end, "界🙂".to_string())); + for cursor in [start, start + 1, end] { + assert_eq!( + current_prefixed_token_range(&textarea(text, cursor), '@', false), + expected + ); + } +} + +#[test] +fn dollar_query_classifies_shell_and_skill_syntax() { + assert_eq!(dollar_query_kind(""), DollarQueryKind::Completable); + assert_eq!( + dollar_query_kind("home:search"), + DollarQueryKind::Completable + ); + assert_eq!(dollar_query_kind("HOME"), DollarQueryKind::ShellVariable); + for query in ["0", "1", "12", "-", "_"] { + assert_eq!( + dollar_query_kind(query), + DollarQueryKind::DefiniteShellParameter + ); + assert!(!dollar_query_is_completable(query)); + } + for query in ["1_suffix", "1foo", "-x"] { + assert_eq!( + dollar_query_kind(query), + DollarQueryKind::AmbiguousShellParameter + ); + assert!(!dollar_query_is_completable(query)); + } + assert_eq!(dollar_query_kind("{HOME}"), DollarQueryKind::Invalid); +} + +#[test] +fn dollar_query_classifies_shell_parameter_edge_cases() { + assert_eq!( + dollar_query_kind("$HOME".trim_start_matches('$')), + DollarQueryKind::ShellVariable + ); + assert_eq!( + dollar_query_kind("$1".trim_start_matches('$')), + DollarQueryKind::DefiniteShellParameter + ); + assert_eq!( + dollar_query_kind("$1_suffix".trim_start_matches('$')), + DollarQueryKind::AmbiguousShellParameter + ); + assert_eq!( + dollar_query_kind("$-x".trim_start_matches('$')), + DollarQueryKind::AmbiguousShellParameter + ); + assert_eq!( + dollar_query_kind("$-".trim_start_matches('$')), + DollarQueryKind::DefiniteShellParameter + ); + assert_eq!( + dollar_query_kind("$_".trim_start_matches('$')), + DollarQueryKind::DefiniteShellParameter + ); +} diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/file_search_popup.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/file_search_popup.rs index 0d46af666..2fd985b33 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer/file_search_popup.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/file_search_popup.rs @@ -143,7 +143,7 @@ impl FileSearchPopup { Style::default() }; Line::from(vec![ - Span::styled(if selected { "> " } else { " " }, style), + Span::styled(if selected { "› " } else { " " }, style), Span::styled(item.path.clone(), style), ]) }) diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state.rs index c8ddb70d9..7f1ea1dfb 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state.rs @@ -3,9 +3,9 @@ //! The composer owns popup visibility and dismissal. `App` only routes events and renders the //! current projection, which keeps slash completion state from being duplicated across hosts. +use super::super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; use super::skill_popup::SkillPopup; -use crate::command_popup::CommandPopup; use std::ops::Range; /// One token occurrence whose autocomplete popup should remain hidden. @@ -64,6 +64,9 @@ pub(super) enum ActivePopup { pub(super) struct PopupState { pub(super) active: ActivePopup, pub(super) dismissed_command_token: Option, + pub(super) dismissed_file_token: Option, + pub(super) dismissed_skill_token: Option, + pub(super) file_search_requested_query: Option, } impl ActivePopup { diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state_tests.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state_tests.rs index fb6792698..7ddf9f6bf 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state_tests.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/popup_state_tests.rs @@ -1,11 +1,14 @@ use super::*; -use crate::command_popup::CommandPopup; +use crate::bottom_pane::command_popup::CommandPopup; #[test] fn popup_state_has_single_active_command_popup() { let mut state = PopupState { active: ActivePopup::Command(CommandPopup::for_composer("/").expect("popup")), dismissed_command_token: None, + dismissed_file_token: None, + dismissed_skill_token: None, + file_search_requested_query: None, }; assert!(state.active()); diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/skill_popup.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/skill_popup.rs index 14f9ed258..12be3c1a7 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer/skill_popup.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/skill_popup.rs @@ -127,7 +127,7 @@ impl SkillPopup { let name = skill_display_name(skill); let description = skill_description(skill); let mut spans = vec![ - Span::styled(if selected { "> " } else { " " }, style), + Span::styled(if selected { "› " } else { " " }, style), Span::styled(format!("${name}"), style), ]; if !description.is_empty() { diff --git a/lime-rs/crates/tui/src/bottom_pane/chat_composer/slash_input.rs b/lime-rs/crates/tui/src/bottom_pane/chat_composer/slash_input.rs index ef668e3f6..a9b1ff571 100644 --- a/lime-rs/crates/tui/src/bottom_pane/chat_composer/slash_input.rs +++ b/lime-rs/crates/tui/src/bottom_pane/chat_composer/slash_input.rs @@ -3,7 +3,7 @@ //! This module is the composer-facing fact source. The command catalog remains in //! [`crate::slash_command`], while lifecycle and rendering stay in the popup owner. -use crate::command_popup::CommandPopup; +use super::super::command_popup::CommandPopup; use crate::slash_command::command_from_prompt as parse_command_from_prompt; pub(super) fn command_popup(text: &str) -> super::ActivePopup { diff --git a/lime-rs/crates/tui/src/bottom_pane/command_popup.rs b/lime-rs/crates/tui/src/bottom_pane/command_popup.rs new file mode 100644 index 000000000..a247c4920 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/command_popup.rs @@ -0,0 +1,330 @@ +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::layout::Rect; +use ratatui::style::{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::{command_filter, SlashCommand}; +use crate::style::{accent_style, muted_style}; + +const MAX_VISIBLE_ROWS: usize = 8; + +/// Actions returned to the composer host after handling one popup event. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CommandPopupAction { + Pass, + Consumed, + Cancel, + Complete(SlashCommand), + Execute(SlashCommand), +} + +/// Slash-command completion state owned by the bottom pane composer. +/// +/// The command catalog remains in `slash_command`; this type owns only the popup +/// lifecycle, selection, and terminal rendering. Keeping those responsibilities +/// together matches the Codex bottom-pane owner without introducing a second +/// command parser. +#[derive(Debug, Clone)] +pub(crate) struct CommandPopup { + filter: String, + selected: usize, +} + +impl CommandPopup { + pub(crate) fn for_composer(text: &str) -> Option { + let filter = command_filter(text)?.to_ascii_lowercase(); + let popup = Self { + filter, + selected: 0, + }; + (!popup.matches().is_empty()).then_some(popup) + } + + pub(crate) fn update(&mut self, text: &str) -> bool { + let Some(filter) = command_filter(text) else { + return false; + }; + self.filter = filter.to_ascii_lowercase(); + let matches = self.matches(); + if matches.is_empty() { + return false; + } + self.selected = self.selected.min(matches.len() - 1); + true + } + + pub(crate) fn commands(&self) -> Vec { + self.matches() + } + + pub(crate) fn selected(&self) -> Option { + self.matches().get(self.selected).copied() + } + + pub(crate) fn handle_event(&mut self, event: &Event) -> CommandPopupAction { + let Event::Key(key) = event else { + return CommandPopupAction::Pass; + }; + if key.kind != KeyEventKind::Press { + return CommandPopupAction::Pass; + } + match key.code { + KeyCode::Up | KeyCode::Char('p') | KeyCode::Char('k') + if key.code == KeyCode::Up || key.modifiers.contains(KeyModifiers::CONTROL) => + { + let len = self.matches().len(); + if len > 0 { + self.selected = self.selected.checked_sub(1).unwrap_or(len - 1); + } + CommandPopupAction::Consumed + } + KeyCode::Down | KeyCode::Char('n') | KeyCode::Char('j') + if key.code == KeyCode::Down || key.modifiers.contains(KeyModifiers::CONTROL) => + { + let len = self.matches().len(); + if len > 0 { + self.selected = (self.selected + 1) % len; + } + CommandPopupAction::Consumed + } + KeyCode::Esc => CommandPopupAction::Cancel, + KeyCode::Tab => self + .selected() + .map(CommandPopupAction::Complete) + .unwrap_or(CommandPopupAction::Consumed), + KeyCode::Enter => self + .selected() + .map(|command| { + if command.requires_argument() { + CommandPopupAction::Complete(command) + } else { + CommandPopupAction::Execute(command) + } + }) + .unwrap_or(CommandPopupAction::Consumed), + _ => CommandPopupAction::Pass, + } + } + + fn matches(&self) -> Vec { + SlashCommand::ALL + .into_iter() + .filter(|command| command.command().starts_with(&self.filter)) + .collect() + } +} + +pub(crate) fn render( + frame: &mut Frame<'_>, + composer_area: Rect, + popup: &CommandPopup, + locale: Locale, +) { + let commands = popup.commands(); + if commands.is_empty() || composer_area.y == 0 || composer_area.width == 0 { + return; + } + let visible_rows = commands + .len() + .min(MAX_VISIBLE_ROWS) + .min(usize::from(composer_area.y)); + let height = u16::try_from(visible_rows).unwrap_or(u16::MAX); + if height == 0 { + return; + } + let area = Rect::new( + composer_area.x, + composer_area.y.saturating_sub(height), + composer_area.width, + height, + ); + // Keep the selected command inside the visible window as the user navigates a long catalog. + // The popup remains anchored to the composer; only its slice moves. + let selected = popup + .selected() + .and_then(|selected| commands.iter().position(|command| *command == selected)); + let start = selected + .map(|selected| selected.saturating_sub(visible_rows.saturating_sub(1))) + .unwrap_or_default(); + let lines = commands + .into_iter() + .enumerate() + .skip(start) + .take(visible_rows) + .map(|(index, command)| { + let selected = index == popup.selected; + let marker = if selected { "› " } else { " " }; + let command_style = if selected { + accent_style() + } else { + Style::default().add_modifier(Modifier::BOLD) + }; + truncate_line_with_ellipsis_if_overflow( + Line::from(vec![ + Span::styled(marker, command_style), + Span::styled(format!("/{}", command.command()), command_style), + Span::raw(" "), + Span::styled(command.description(locale), muted_style()), + ]), + usize::from(area.width), + ) + }) + .collect::>(); + frame.render_widget(Clear, area); + frame.render_widget(Paragraph::new(lines), area); +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{KeyEvent, KeyModifiers}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + #[test] + fn filters_by_prefix_and_wraps_selection() { + let mut popup = CommandPopup::for_composer("/").expect("popup"); + assert_eq!(popup.selected(), Some(SlashCommand::Model)); + assert_eq!( + popup.handle_event(&Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE,))), + CommandPopupAction::Consumed + ); + assert_eq!(popup.selected(), SlashCommand::ALL.last().copied()); + assert!(popup.update("/per")); + assert_eq!(popup.commands(), vec![SlashCommand::Permissions]); + assert_eq!(popup.selected(), Some(SlashCommand::Permissions)); + assert!(!popup.update("/unknown")); + } + + #[test] + fn enter_executes_immediate_commands_and_completes_argument_commands() { + let event = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + let mut model = CommandPopup::for_composer("/m").expect("model popup"); + assert_eq!( + model.handle_event(&event), + CommandPopupAction::Execute(SlashCommand::Model) + ); + let mut effort = CommandPopup::for_composer("/e").expect("effort popup"); + assert_eq!( + effort.handle_event(&event), + CommandPopupAction::Complete(SlashCommand::Effort) + ); + } + + #[test] + fn control_navigation_matches_codex_list_bindings() { + let mut popup = CommandPopup::for_composer("/").expect("popup"); + let first = popup.selected().expect("first command"); + + popup.handle_event(&Event::Key(KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + ))); + let second = popup.selected().expect("second command"); + assert_ne!(second, first); + + popup.handle_event(&Event::Key(KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::CONTROL, + ))); + assert_eq!(popup.selected(), Some(first)); + + popup.handle_event(&Event::Key(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::CONTROL, + ))); + assert_eq!( + popup.selected(), + Some(popup.commands().last().copied().expect("last")) + ); + } + + #[test] + fn non_key_and_key_release_events_do_not_change_popup_state() { + let mut popup = CommandPopup::for_composer("/m").expect("popup"); + let selected = popup.selected(); + assert_eq!( + popup.handle_event(&Event::Resize(20, 5)), + CommandPopupAction::Pass + ); + assert_eq!(popup.selected(), selected); + let mut release = KeyEvent::new(KeyCode::Down, KeyModifiers::NONE); + release.kind = KeyEventKind::Release; + assert_eq!( + popup.handle_event(&Event::Key(release)), + CommandPopupAction::Pass + ); + assert_eq!(popup.selected(), selected); + } + + #[test] + fn empty_filter_keeps_catalog_order_and_narrow_render_is_bounded() { + let popup = CommandPopup::for_composer("/").expect("popup"); + assert_eq!(popup.commands().first(), Some(&SlashCommand::Model)); + let mut terminal = Terminal::new(TestBackend::new(10, 4)).expect("terminal"); + terminal + .draw(|frame| render(frame, Rect::new(0, 3, 10, 1), &popup, Locale::EnUs)) + .expect("draw"); + let row = (0..10) + .map(|x| terminal.backend().buffer()[(x, 2)].symbol()) + .collect::(); + assert!(row.chars().count() <= 10); + } + + #[test] + fn test_backend_renders_commands_and_localized_descriptions() { + let popup = CommandPopup::for_composer("/").expect("popup"); + let mut terminal = Terminal::new(TestBackend::new(72, 10)).expect("terminal"); + terminal + .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) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n"); + let compact = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + + assert!(text.contains("/model")); + assert!(compact.contains("选择模型"), "{text}"); + assert!(text.contains("/copy")); + } + + #[test] + fn long_catalog_keeps_selected_row_visible_with_a_bounded_popup() { + let mut popup = CommandPopup::for_composer("/").expect("popup"); + for _ in 0..10 { + let _ = popup.handle_event(&Event::Key(KeyEvent::new( + KeyCode::Down, + KeyModifiers::NONE, + ))); + } + let selected = popup.selected().expect("selected command"); + let mut terminal = Terminal::new(TestBackend::new(72, 16)).expect("terminal"); + terminal + .draw(|frame| render(frame, Rect::new(0, 15, 72, 1), &popup, Locale::EnUs)) + .expect("draw"); + let buffer = terminal.backend().buffer(); + let rows_with_marker = (0..buffer.area.height) + .filter(|y| { + let row = (0..buffer.area.width) + .map(|x| buffer[(x, *y)].symbol()) + .collect::(); + row.contains(&format!("› /{}", selected.command())) + }) + .count(); + assert_eq!(rows_with_marker, 1); + assert!(popup.commands().len() > MAX_VISIBLE_ROWS); + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/footer.rs b/lime-rs/crates/tui/src/bottom_pane/footer.rs index 02f886bb0..8e9a40c8f 100644 --- a/lime-rs/crates/tui/src/bottom_pane/footer.rs +++ b/lime-rs/crates/tui/src/bottom_pane/footer.rs @@ -4,16 +4,34 @@ //! own session state or infer runtime status, keeping the same boundary as Codex's footer owner. use ratatui::layout::{Position, Rect}; -use ratatui::style::{Color, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::app::App; -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; +use crate::line_truncation::{line_width, truncate_line_with_ellipsis_if_overflow}; +use crate::style::{accent_style, footer_hint_label_style}; use crate::width::usable_content_width_u16; +const FOOTER_INDENT_COLS: u16 = 1; +const FOOTER_CONTEXT_GAP_COLS: u16 = 1; + pub(crate) fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { + if app.bottom_pane.is_active() { + let hint = app + .bottom_pane + .footer_hint(app.locale, usize::from(area.width.saturating_sub(1))); + if let Some(hint) = hint { + let width = + usable_content_width_u16(area.width, FOOTER_INDENT_COLS).unwrap_or_default(); + let line = Line::from(Span::styled(format!(" {hint}"), footer_hint_label_style())); + frame.render_widget( + Paragraph::new(truncate_line_with_ellipsis_if_overflow(line, width)), + area, + ); + return; + } + } let vim_indicator = app.composer.vim_mode_indicator_span(); if let Some(line) = app.composer.footer_flash() { let mut spans = line.spans.clone(); @@ -25,7 +43,7 @@ pub(crate) fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { let width = usable_content_width_u16(area.width, 1).unwrap_or_default(); let mut spans = vec![Span::styled( format!(" {}{}", app.locale.history_search_label(), query), - Style::default().fg(Color::DarkGray), + footer_hint_label_style(), )]; append_vim_indicator(&mut spans, vim_indicator.clone()); let line = truncate_line_with_ellipsis_if_overflow(Line::from(spans), width); @@ -44,44 +62,107 @@ pub(crate) fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { crate::vim_search::SearchDirection::Backward => "?", }; let width = usable_content_width_u16(area.width, 1).unwrap_or_default(); - let mut spans = vec![Span::styled( - format!(" {prefix}{query}"), - Style::default().fg(Color::Cyan), - )]; + let mut spans = vec![Span::styled(format!(" {prefix}{query}"), accent_style())]; append_vim_indicator(&mut spans, vim_indicator.clone()); let line = truncate_line_with_ellipsis_if_overflow(Line::from(spans), width); frame.render_widget(Paragraph::new(line), area); return; } if app.composer.footer_has_draft() { - let width = usable_content_width_u16(area.width, 1).unwrap_or_default(); - let mut spans = vec![Span::styled( - " draft ready", - Style::default().fg(Color::DarkGray), - )]; - append_vim_indicator(&mut spans, vim_indicator.clone()); - let line = truncate_line_with_ellipsis_if_overflow(Line::from(spans), width); - frame.render_widget(Paragraph::new(line), area); + let active_turn = app.projection.active_turn_id().is_some(); + let hint_kind = if active_turn { + SummaryHintKind::QueueMessage + } else { + SummaryHintKind::DraftReady + }; + let default_text = if active_turn { + app.locale.queue_message_hint() + } else { + app.locale.draft_ready_hint() + }; + render_summary_footer( + frame, + area, + hint_kind, + default_text, + active_agent_context(app), + vim_indicator, + app.locale.queue_short_hint(), + ); return; } + // Keep an actionable footer visible while idle. Codex reserves this row for the shortcut + // entry point instead of leaving a blank line; an active turn still gets its canonical id. let active = app .projection .active_turn_id() .map(|turn| format!(" {} {}", app.locale.turn_label(), turn)) - .unwrap_or_default(); - let active_agent = app - .agent_navigation + .unwrap_or_else(|| format!(" {}", app.locale.shortcuts_hint())); + render_summary_footer( + frame, + area, + SummaryHintKind::None, + &active, + active_agent_context(app), + vim_indicator, + app.locale.queue_short_hint(), + ); +} + +fn active_agent_context(app: &App) -> Option> { + 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 mut spans = vec![Span::styled( - format!(" {active}{active_agent}"), - Style::default().fg(Color::DarkGray), + .map(|label| Line::from(Span::styled(label, footer_hint_label_style()))) +} + +fn render_summary_footer( + frame: &mut Frame<'_>, + area: Rect, + hint_kind: SummaryHintKind, + default_text: &str, + context: Option>, + vim_indicator: Option>, + queue_short_text: &str, +) { + let mut default_spans = vec![Span::styled( + format!(" {default_text}"), + footer_hint_label_style(), )]; - append_vim_indicator(&mut spans, vim_indicator); - let line = truncate_line_with_ellipsis_if_overflow(Line::from(spans), width); - frame.render_widget(Paragraph::new(line), area); + append_vim_indicator(&mut default_spans, vim_indicator.clone()); + let default_line = Line::from(default_spans); + + let queue_short_line = (hint_kind == SummaryHintKind::QueueMessage).then(|| { + let mut spans = vec![Span::styled( + format!(" {queue_short_text}"), + footer_hint_label_style(), + )]; + append_vim_indicator(&mut spans, vim_indicator); + Line::from(spans) + }); + let context_width = context.as_ref().map(line_width).unwrap_or(0) as u16; + let (summary, show_context) = single_line_footer_layout( + area, + hint_kind, + default_line.clone(), + queue_short_line, + context_width, + ); + + let left = match summary { + SummaryLeft::Default => default_line, + SummaryLeft::Custom(line) => line, + SummaryLeft::None => Line::from(Vec::>::new()), + }; + let width = usable_content_width_u16(area.width, FOOTER_INDENT_COLS).unwrap_or_default(); + frame.render_widget( + Paragraph::new(truncate_line_with_ellipsis_if_overflow(left, width)), + area, + ); + if show_context { + if let Some(context) = context { + render_context_right(area, frame.buffer_mut(), &context); + } + } } fn append_vim_indicator(spans: &mut Vec>, indicator: Option>) { @@ -91,9 +172,131 @@ fn append_vim_indicator(spans: &mut Vec>, indicator: Option), + None, +} + +/// Choose the most useful single-line footer variant for the available width. +/// +/// Queue hints are deliberately kept ahead of ambient context: when the full queue hint cannot +/// share a row with the agent label, the context is hidden first and the hint is shortened last. +pub(crate) fn single_line_footer_layout( + area: Rect, + hint_kind: SummaryHintKind, + default_line: Line<'static>, + queue_short_line: Option>, + context_width: u16, +) -> (SummaryLeft, bool) { + let default_width = line_width(&default_line) as u16; + if default_width > 0 && can_show_left_with_context(area, default_width, context_width) { + return (SummaryLeft::Default, true); + } + + if hint_kind == SummaryHintKind::QueueMessage { + if let Some(short) = queue_short_line.as_ref() { + let short_width = line_width(short) as u16; + if short_width > 0 && can_show_left_with_context(area, short_width, context_width) { + return (SummaryLeft::Custom(short.clone()), true); + } + } + // Queueing is actionable, so hide passive context before shortening or dropping it. + if default_width > 0 && left_fits(area, default_width) { + return (SummaryLeft::Default, false); + } + if let Some(short) = queue_short_line { + if left_fits(area, line_width(&short) as u16) { + return (SummaryLeft::Custom(short), false); + } + } + } else if default_width > 0 && left_fits(area, default_width) { + return (SummaryLeft::Default, false); + } + + // If no left content fits, retain a right context only when it can be rendered on its own. + ( + SummaryLeft::None, + context_width > 0 && right_aligned_x(area, context_width).is_some(), + ) +} + +fn left_fits(area: Rect, left_width: u16) -> bool { + left_width <= area.width.saturating_sub(FOOTER_INDENT_COLS) +} + +fn right_aligned_x(area: Rect, content_width: u16) -> Option { + if area.is_empty() || content_width == 0 { + return None; + } + let max_width = area.width.saturating_sub(FOOTER_INDENT_COLS); + if max_width == 0 || content_width > max_width { + return None; + } + Some( + area.x + .saturating_add(area.width) + .saturating_sub(content_width) + .saturating_sub(FOOTER_INDENT_COLS), + ) +} + +pub(crate) fn can_show_left_with_context(area: Rect, left_width: u16, context_width: u16) -> bool { + let available = area.width.saturating_sub(FOOTER_INDENT_COLS); + if context_width > available { + return false; + } + let Some(context_x) = right_aligned_x(area, context_width) else { + return true; + }; + if left_width == 0 { + return true; + } + let left_extent = area + .x + .saturating_add(FOOTER_INDENT_COLS) + .saturating_add(left_width) + .saturating_add(FOOTER_CONTEXT_GAP_COLS); + left_extent <= context_x +} + +pub(crate) fn render_context_right( + area: Rect, + buf: &mut ratatui::buffer::Buffer, + line: &Line<'static>, +) { + let Some(mut x) = right_aligned_x(area, line_width(line) as u16) else { + return; + }; + let y = area.y + area.height.saturating_sub(1); + let max_x = area.x.saturating_add(area.width); + for span in &line.spans { + if x >= max_x { + break; + } + let span_width = crate::width::display_width(span.content.as_ref()) as u16; + if span_width == 0 { + continue; + } + let draw_width = span_width.min(max_x.saturating_sub(x)); + buf.set_span(x, y, span, draw_width); + x = x.saturating_add(span_width); + } +} + #[cfg(test)] mod tests { - use super::render_footer; + use super::{ + line_width, render_footer, single_line_footer_layout, SummaryHintKind, SummaryLeft, + }; use crate::app::App; use crate::locale::Locale; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -123,6 +326,129 @@ mod tests { assert!(rendered_text(&app).contains("draft ready")); } + #[test] + fn idle_footer_keeps_localized_shortcut_entry_point_across_supported_widths() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + let mut app = App::default(); + app.set_locale(locale); + for width in [40, 80, 120] { + let text = rendered_text_at_width(&app, width); + let compact = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + let expected = locale + .shortcuts_hint() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!( + compact.contains(&expected), + "{locale:?} at {width}: {text:?}" + ); + assert_eq!( + text.lines().count(), + 1, + "footer must remain a single row for {locale:?} at {width}: {text:?}" + ); + } + } + } + + #[test] + fn idle_footer_drops_ambient_agent_context_as_one_unit_when_it_cannot_fit() { + 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 text = rendered_text_at_width(&app, 20); + assert!(text.contains("? for shortcuts"), "{text}"); + assert!( + !text.contains("Robie") && !text.contains("explorer"), + "agent context must be hidden as one unit: {text}" + ); + } + + #[test] + fn active_draft_prefers_queue_hint_and_hides_context_when_narrow() { + 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()); + app.start_turn("turn-1".to_string()); + app.composer.insert("draft"); + + let text = rendered_text_at_width(&app, 30); + assert!(text.contains("Tab to queue message"), "{text}"); + assert!(!text.contains("Robie [explorer]"), "{text}"); + } + + #[test] + fn queue_hint_shortens_before_it_disappears() { + let default = ratatui::text::Line::from(" Tab to queue message"); + let short = ratatui::text::Line::from(" Tab to queue"); + let (left, show_context) = single_line_footer_layout( + ratatui::layout::Rect::new(0, 0, 14, 1), + SummaryHintKind::QueueMessage, + default, + Some(short), + 14, + ); + assert!(!show_context); + match left { + SummaryLeft::Custom(line) => assert_eq!(line_width(&line), 13), + other => panic!("expected shortened queue hint, got {other:?}"), + } + } + + #[test] + fn cjk_and_emoji_context_widths_do_not_overlap_left_hint() { + let left = ratatui::text::Line::from(" Tab to queue"); + let context = ratatui::text::Line::from("界🙂"); + let (summary, show_context) = single_line_footer_layout( + ratatui::layout::Rect::new(0, 0, 20, 1), + SummaryHintKind::QueueMessage, + left.clone(), + None, + line_width(&context) as u16, + ); + assert!(matches!(summary, SummaryLeft::Default)); + assert!(show_context); + assert!(line_width(&left) + line_width(&context) + 3 <= 20); + } + + #[test] + fn queue_and_draft_footer_hints_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(!locale.queue_message_hint().is_empty()); + assert!(!locale.queue_short_hint().is_empty()); + assert!(!locale.draft_ready_hint().is_empty()); + } + } + #[test] fn renders_localized_history_search_query() { let mut app = App::default(); diff --git a/lime-rs/crates/tui/src/bottom_pane/mcp_server_elicitation.rs b/lime-rs/crates/tui/src/bottom_pane/mcp_server_elicitation.rs index 18e69b1ec..feb2587f1 100644 --- a/lime-rs/crates/tui/src/bottom_pane/mcp_server_elicitation.rs +++ b/lime-rs/crates/tui/src/bottom_pane/mcp_server_elicitation.rs @@ -18,8 +18,12 @@ use ratatui::Frame; use serde_json::{Map, Value}; use super::{AppServerResponse, TextArea, TextAreaState}; +use crate::bottom_pane::selection_row_layout::{visible_item_window, MAX_POPUP_ROWS}; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; +use crate::style::{accent_style, muted_style}; use crate::width::display_width; +use crate::wrapping::{word_wrap_line, RtOptions}; const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind"; const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call"; @@ -487,19 +491,6 @@ impl McpServerElicitationOverlay { !self.is_select_field() } - fn input_line_offset(&self) -> usize { - let mut offset = 4; - if self - .fields - .get(self.current_field) - .and_then(|field| field.description.as_ref()) - .is_some() - { - offset += 1; - } - offset - } - #[cfg(test)] pub(super) fn is_complete(&self) -> bool { self.done @@ -509,6 +500,52 @@ impl McpServerElicitationOverlay { pub(super) fn lines_with_locale( overlay: &McpServerElicitationOverlay, locale: Locale, +) -> Vec> { + lines_with_locale_inner(overlay, locale, None) +} + +pub(super) fn lines_with_locale_with_width( + overlay: &McpServerElicitationOverlay, + locale: Locale, + width: usize, +) -> Vec> { + let width = width.max(1); + if width == usize::MAX { + return lines_with_locale(overlay, locale); + } + let lines = lines_with_locale_inner(overlay, locale, Some(width)); + + let footer_index = lines.len().saturating_sub(1); + lines + .into_iter() + .enumerate() + .flat_map(|(index, line)| { + if index == footer_index { + return vec![line].into_iter(); + } + if is_input_line(&line) { + return split_input_lines(line) + .into_iter() + .map(|line| truncate_line_with_ellipsis_if_overflow(line, width)) + .collect::>() + .into_iter(); + } + if is_option_line(&line) { + return vec![truncate_line_with_ellipsis_if_overflow(line, width)].into_iter(); + } + word_wrap_line(&line, RtOptions::new(width).break_words(true)) + .into_iter() + .map(line_to_owned) + .collect::>() + .into_iter() + }) + .collect() +} + +fn lines_with_locale_inner( + overlay: &McpServerElicitationOverlay, + locale: Locale, + width: Option, ) -> Vec> { let Some(field) = overlay.fields.get(overlay.current_field) else { return vec![Line::from(locale.mcp_elicitation_invalid())]; @@ -521,7 +558,7 @@ pub(super) fn lines_with_locale( Line::from(overlay.message.clone()), Line::styled( locale.mcp_elicitation_progress(overlay.current_field + 1, overlay.fields.len()), - Style::default().fg(Color::DarkGray), + muted_style(), ), ]; if !field.label.is_empty() { @@ -531,10 +568,7 @@ pub(super) fn lines_with_locale( )); } if let Some(description) = field.description.as_ref().filter(|value| !value.is_empty()) { - lines.push(Line::styled( - description.clone(), - Style::default().fg(Color::DarkGray), - )); + lines.push(Line::styled(description.clone(), muted_style())); } match &field.input { @@ -553,7 +587,7 @@ pub(super) fn lines_with_locale( Style::default() }; lines.push(Line::from(vec![ - Span::styled("> ", Style::default().fg(Color::Cyan)), + Span::styled("› ", accent_style()), Span::styled(value, style), ])); } @@ -562,9 +596,11 @@ pub(super) fn lines_with_locale( Some(McpFieldState::Select { selected, .. }) => *selected, _ => None, }; - for (index, option) in options.iter().enumerate() { + let selected_index = selected.unwrap_or(0); + let (start, end) = visible_item_window(selected_index, options.len(), MAX_POPUP_ROWS); + for (index, option) in options.iter().enumerate().skip(start).take(end - start) { let is_selected = selected == Some(index); - let prefix = if is_selected { ">" } else { " " }; + let prefix = if is_selected { "›" } else { " " }; let label = match (&option.value, overlay.response_mode) { (Value::Bool(value), _) => { locale.mcp_elicitation_boolean_option(*value).to_string() @@ -580,7 +616,7 @@ pub(super) fn lines_with_locale( _ => option.label.clone(), }; let style = if is_selected { - Style::default().fg(Color::Cyan) + accent_style() } else { Style::default() }; @@ -597,39 +633,210 @@ pub(super) fn lines_with_locale( Style::default().fg(Color::Red), )); } - lines.push(Line::styled( - locale.mcp_elicitation_controls(overlay.is_select_field()), - Style::default().fg(Color::DarkGray), + lines.extend(footer_control_lines( + locale, + overlay.is_select_field(), + width, )); lines } -pub(super) fn lines_with_locale_with_width( - overlay: &McpServerElicitationOverlay, - locale: Locale, - _width: usize, -) -> Vec> { - lines_with_locale(overlay, locale) +fn is_option_line(line: &Line<'_>) -> bool { + let text = line.to_string(); + let text = text.trim_start_matches(['›', '>', ' ']); + text.as_bytes().first().is_some_and(u8::is_ascii_digit) && text.contains(". ") +} + +fn is_input_line(line: &Line<'_>) -> bool { + line.to_string().starts_with(['›', '>']) +} + +/// Split the editable line at explicit newlines before applying the width projection. +/// +/// `Line` may contain a newline inside a span, but ratatui lays out each physical row +/// independently. Keeping the split here makes the rendered rows and cursor calculation share +/// the same source of truth, including continuation rows that do not carry the `›` prompt. +fn split_input_lines(line: Line<'static>) -> Vec> { + let Line { + style, + alignment, + spans, + } = line; + let mut rows = vec![Vec::>::new()]; + for span in spans { + let content = span.content.into_owned(); + let mut segments = content.split('\n').peekable(); + while let Some(segment) = segments.next() { + if !segment.is_empty() { + rows.last_mut() + .expect("input row exists") + .push(Span::styled(segment.to_string(), span.style)); + } + if segments.peek().is_some() { + rows.push(Vec::new()); + } + } + } + rows.into_iter() + .map(|spans| Line { + style, + alignment, + spans, + }) + .collect() +} + +fn line_to_owned(line: Line<'_>) -> Line<'static> { + let style = line.style; + let spans = line + .spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect::>(); + Line::from(spans).style(style) +} + +fn footer_control_lines(locale: Locale, select: bool, width: Option) -> Vec> { + let full = locale.mcp_elicitation_controls(select); + let Some(width) = width else { + return vec![Line::styled(full, muted_style())]; + }; + let width = width.max(1); + if display_width(&full) <= width { + return vec![Line::styled(full, muted_style())]; + } + + // Locale strings intentionally keep two spaces between tips. Reusing those boundaries lets + // the wide layout remain byte-for-byte stable while allowing narrow terminals to pack each + // action independently. Submit and cancel stay ahead of navigation hints so the primary + // action set remains visible when the footer needs multiple rows. + let segments = full + .split(" ") + .filter(|segment| !segment.is_empty()) + .map(str::to_owned) + .collect::>(); + if segments.len() < 2 { + return vec![Line::styled( + compact_control_segment(&full, width), + muted_style(), + )]; + } + let submit_index = segments + .iter() + .position(|segment| contains_submit_hint(segment)) + .unwrap_or(0); + let cancel_index = segments + .iter() + .position(|segment| contains_cancel_hint(segment)) + .unwrap_or(segments.len().saturating_sub(1)); + let cancel_segment = (cancel_index != submit_index).then(|| segments[cancel_index].clone()); + let mut ordered = Vec::with_capacity(segments.len()); + ordered.push(segments[submit_index].clone()); + for (index, segment) in segments.into_iter().enumerate() { + if index != submit_index && index != cancel_index { + ordered.push(segment); + } + } + if let Some(cancel_segment) = cancel_segment { + ordered.push(cancel_segment); + } + + let mut rows = Vec::::new(); + let mut current = String::new(); + for segment in ordered { + let segment = compact_control_segment(&segment, width); + if current.is_empty() { + current = segment; + continue; + } + let candidate = format!("{current} {segment}"); + if display_width(&candidate) <= width { + current = candidate; + } else { + rows.push(current); + current = segment; + } + } + if !current.is_empty() { + rows.push(current); + } + rows.into_iter() + .map(|line| Line::styled(line, muted_style())) + .collect() +} + +fn contains_submit_hint(segment: &str) -> bool { + segment.contains("Enter") + || segment.contains("↵") + || segment.contains("确认") + || segment.contains("確定") + || segment.contains("확인") +} + +fn contains_cancel_hint(segment: &str) -> bool { + segment.contains("Esc") + || segment.contains('⎋') + || segment.contains("取消") + || segment.contains("キャンセル") + || segment.contains("취소") +} + +fn compact_control_segment(segment: &str, width: usize) -> String { + if display_width(segment) <= width { + return segment.to_owned(); + } + let candidates = if contains_cancel_hint(segment) { + vec!["Esc", "⎋"] + } else if contains_submit_hint(segment) { + vec!["Enter", "↵"] + } else if segment.contains('↑') || segment.contains('↓') { + vec!["↑/↓", "↑↓", "↑", "↓"] + } else if segment.contains("Tab") + || segment.contains("欄位") + || segment.contains("字段") + || segment.contains("フィールド") + || segment.contains("필드") + { + vec!["Tab", "⇥"] + } else { + Vec::new() + }; + if let Some(candidate) = candidates + .into_iter() + .find(|candidate| display_width(candidate) <= width) + { + return candidate.to_owned(); + } + truncate_line_with_ellipsis_if_overflow(Line::from(segment.to_owned()), width).to_string() } pub(super) fn set_cursor_position( frame: &mut Frame<'_>, inner: Rect, overlay: &McpServerElicitationOverlay, + content: &[Line<'static>], ) { if !overlay.is_text_field() || inner.is_empty() { return; } let before = &overlay.text_area.text()[..overlay.text_area.cursor()]; + let current_line = before.rsplit('\n').next().unwrap_or(before); + let input_row = content + .iter() + .position(is_input_line) + .unwrap_or_else(|| content.len().saturating_sub(1)); + let prefix_width = if before.contains('\n') { 0 } else { 2 }; let x = inner .x - .saturating_add(2) - .saturating_add(u16::try_from(display_width(before)).unwrap_or(u16::MAX)) + .saturating_add(prefix_width) + .saturating_add(u16::try_from(display_width(current_line)).unwrap_or(u16::MAX)) .min(inner.right().saturating_sub(1)); - let y = inner - .y - .saturating_add(u16::try_from(overlay.input_line_offset()).unwrap_or(u16::MAX)) - .min(inner.bottom().saturating_sub(1)); + let input_row = input_row.saturating_add(before.matches('\n').count()); + let y = inner.y.saturating_add( + u16::try_from(input_row) + .unwrap_or(u16::MAX) + .min(inner.height.saturating_sub(1)), + ); frame.set_cursor_position(Position::new(x, y)); } @@ -884,6 +1091,9 @@ mod tests { use super::*; use app_server_protocol::protocol::v2::McpServerElicitationRequest; use crossterm::event::KeyModifiers; + use ratatui::backend::TestBackend; + use ratatui::layout::Rect; + use ratatui::Terminal; use serde_json::json; fn key(code: KeyCode) -> KeyEvent { @@ -1158,4 +1368,122 @@ mod tests { assert!(text.contains(locale.mcp_elicitation_controls(true).trim())); } } + + #[test] + fn narrow_select_surface_bounds_rows_and_keeps_cancel_visible() { + let options = (0..12) + .map(|index| format!("choice-{index}")) + .collect::>(); + let mut overlay = overlay(json!({ + "type": "object", + "properties": { + "choice": { "type": "string", "enum": options } + } + })); + for _ in 0..11 { + assert!(overlay.handle_key_event(key(KeyCode::Down)).is_none()); + } + + for width in [1, 4, 12, 24] { + let lines = lines_with_locale_with_width(&overlay, Locale::EnUs, width); + if width >= 8 { + let option_lines = lines + .iter() + .filter(|line| is_option_line(line)) + .collect::>(); + assert_eq!(option_lines.len(), MAX_POPUP_ROWS); + assert!(option_lines + .iter() + .any(|line| line.to_string().starts_with('›'))); + } + assert!(lines + .iter() + .all(|line| display_width(&line.to_string()) <= width)); + let footer = lines.last().expect("footer line").to_string(); + if width >= display_width("Esc") { + assert!(footer.contains("Esc"), "footer={footer:?}, width={width}"); + } + } + } + + #[test] + fn narrow_footer_wraps_actions_in_priority_order_for_all_locales() { + let overlay = overlay(json!({ + "type": "object", + "properties": { + "choice": { "type": "string", "enum": ["one", "two"] }, + "note": { "type": "string" } + }, + "required": ["choice", "note"] + })); + + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + let lines = lines_with_locale_with_width(&overlay, locale, 8); + let footer = lines + .iter() + .skip_while(|line| { + !line.to_string().contains("Enter") + && !line.to_string().contains("确认") + && !line.to_string().contains("確定") + && !line.to_string().contains("확인") + }) + .map(ToString::to_string) + .collect::>(); + assert!(!footer.is_empty(), "missing submit footer for {locale:?}"); + assert!( + footer.last().is_some_and(|line| line.contains("Esc") + || line.contains("取消") + || line.contains("キャンセル") + || line.contains("취소") + || line.contains('⎋')), + "cancel action must remain last for {locale:?}: {footer:?}" + ); + assert!( + lines + .iter() + .all(|line| display_width(&line.to_string()) <= 8), + "footer overflow for {locale:?}: {lines:?}" + ); + } + } + + #[test] + fn narrow_multiline_text_keeps_physical_rows_and_cursor_aligned() { + let mut overlay = overlay(json!({ + "type": "object", + "properties": { + "answer": { "type": "string", "title": "回答" } + }, + "required": ["answer"] + })); + overlay.handle_paste("你好\n👩🏽‍💻"); + + let width = 12usize; + let lines = lines_with_locale_with_width(&overlay, Locale::ZhCn, width); + let input_index = lines.iter().position(is_input_line).expect("editable row"); + assert_eq!(lines[input_index].to_string(), "› 你好"); + assert_eq!(lines[input_index + 1].to_string(), "👩🏽‍💻"); + assert!(lines + .iter() + .all(|line| display_width(&line.to_string()) <= width)); + + let mut terminal = Terminal::new(TestBackend::new(width as u16, 16)).expect("terminal"); + terminal + .draw(|frame| { + set_cursor_position(frame, Rect::new(0, 0, width as u16, 16), &overlay, &lines) + }) + .expect("draw"); + let cursor = terminal.backend().cursor_position(); + assert_eq!( + cursor.y, + u16::try_from(input_index + 1).expect("cursor row") + ); + assert_eq!(cursor.x, display_width("👩🏽‍💻") as u16); + } } diff --git a/lime-rs/crates/tui/src/bottom_pane/mod.rs b/lime-rs/crates/tui/src/bottom_pane/mod.rs index bab3e5f02..e477ed5bf 100644 --- a/lime-rs/crates/tui/src/bottom_pane/mod.rs +++ b/lime-rs/crates/tui/src/bottom_pane/mod.rs @@ -1,12 +1,16 @@ mod approval_overlay; mod chat_composer; +pub(crate) mod command_popup; mod footer; mod mcp_server_elicitation; +pub(crate) mod pending_input_preview; mod render; mod request_user_input; +pub(crate) mod selection_row_layout; mod textarea; use std::collections::VecDeque; +use std::time::{Duration, Instant}; use app_server_protocol::protocol::v2::{ CommandExecutionApprovalDecision, CommandExecutionRequestApprovalResponse, @@ -26,7 +30,7 @@ use request_user_input::RequestUserInputOverlay; pub(crate) use textarea::{TextArea, TextAreaState}; pub(crate) use footer::render_footer; -pub(crate) use render::{desired_height_with_locale, render_with_locale}; +pub(crate) use render::{desired_height_with_locale_for_width, render_with_locale}; #[derive(Debug, Clone, PartialEq)] pub(crate) enum AppServerResponse { @@ -123,6 +127,20 @@ impl PendingInteraction { Self::McpElicitation(request) => request.handle_key_event(key), } } + + fn pre_draw_tick(&mut self, now: Instant) -> Option { + match self { + Self::UserInput(request) => request.pre_draw_tick(now), + Self::Approval(_) | Self::McpElicitation(_) => None, + } + } + + fn next_frame_delay(&self, now: Instant) -> Option { + match self { + Self::UserInput(request) => request.next_frame_delay(now), + Self::Approval(_) | Self::McpElicitation(_) => None, + } + } } #[derive(Debug, Default)] @@ -155,6 +173,20 @@ impl BottomPane { !self.queue.is_empty() } + pub(crate) fn footer_hint( + &self, + locale: crate::locale::Locale, + width: usize, + ) -> Option { + match self.queue.front() { + Some(PendingInteraction::Approval(_)) => Some(locale.approval_controls().to_string()), + Some(PendingInteraction::UserInput(request)) => { + Some(request.footer_hint_for_width(locale, width)) + } + Some(PendingInteraction::McpElicitation(_)) | None => None, + } + } + pub(crate) fn clear(&mut self) { self.queue.clear(); } @@ -169,8 +201,7 @@ impl BottomPane { Event::Paste(text) => { match self.queue.front_mut() { Some(PendingInteraction::UserInput(request)) => { - request.editing = true; - request.composer.insert(&text); + request.handle_paste(&text); } Some(PendingInteraction::McpElicitation(request)) => { request.handle_paste(&text); @@ -188,6 +219,16 @@ impl BottomPane { self.queue.pop_front(); Some(response) } + + pub(crate) fn pre_draw_tick(&mut self, now: Instant) -> Option { + let response = self.queue.front_mut()?.pre_draw_tick(now)?; + self.queue.pop_front(); + Some(response) + } + + pub(crate) fn next_frame_delay(&self, now: Instant) -> Option { + self.queue.front()?.next_frame_delay(now) + } } #[cfg(test)] @@ -243,6 +284,7 @@ mod tests { description: "Continue immediately".to_string(), }]), }], + is_blocking: true, auto_resolution_ms: None, }, }) @@ -453,6 +495,7 @@ mod tests { turn_id: "turn-1".to_string(), item_id: "question-1".to_string(), questions: Vec::new(), + is_blocking: true, auto_resolution_ms: None, }, }) diff --git a/lime-rs/crates/tui/src/bottom_pane/pending_input_preview.rs b/lime-rs/crates/tui/src/bottom_pane/pending_input_preview.rs new file mode 100644 index 000000000..4063b65b7 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/pending_input_preview.rs @@ -0,0 +1,317 @@ +use app_server_protocol::protocol::v2::{QueuedSubmission, UserInput}; +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::{wrap_hyperlink_line, HyperlinkLine}; + +const MAX_VISIBLE_SUBMISSIONS: usize = 2; +const MAX_LINES_PER_SUBMISSION: usize = 2; + +pub(crate) fn desired_height(submissions: &[QueuedSubmission], width: u16, locale: Locale) -> u16 { + u16::try_from(preview_lines(submissions, width, locale).len()).unwrap_or(u16::MAX) +} + +pub(crate) fn render( + frame: &mut Frame<'_>, + area: Rect, + submissions: &[QueuedSubmission], + locale: Locale, +) { + if area.is_empty() { + return; + } + frame.render_widget( + Paragraph::new(preview_lines(submissions, area.width, locale)), + area, + ); +} + +fn preview_lines( + submissions: &[QueuedSubmission], + width: u16, + locale: Locale, +) -> Vec> { + if submissions.is_empty() || width < 4 { + return Vec::new(); + } + + let mut lines = vec![truncate_line_with_ellipsis_if_overflow( + Line::styled( + format!("• {} ({})", locale.status("queued"), submissions.len()), + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ), + usize::from(width), + )]; + for submission in submissions.iter().take(MAX_VISIBLE_SUBMISSIONS) { + lines.extend(submission_preview_lines(submission, width, locale)); + } + if submissions.len() > MAX_VISIBLE_SUBMISSIONS { + lines.push(truncate_line_with_ellipsis_if_overflow( + Line::styled( + format!(" … +{}", submissions.len() - MAX_VISIBLE_SUBMISSIONS), + Style::default().fg(Color::DarkGray), + ), + usize::from(width), + )); + } + if submissions.last().is_some_and(can_restore_submission) { + lines.push(truncate_line_with_ellipsis_if_overflow( + Line::styled( + format!(" {}", locale.edit_queued_input_hint()), + Style::default().fg(Color::DarkGray), + ), + usize::from(width), + )); + } + lines +} + +pub(crate) fn can_restore_submission(submission: &QueuedSubmission) -> bool { + let mut text_count = 0usize; + !submission.input.is_empty() + && submission.input.iter().all(|input| match input { + UserInput::Text { text_elements, .. } => { + text_count += 1; + text_count <= 1 && text_elements.is_empty() + } + UserInput::LocalImage { detail, .. } | UserInput::Image { detail, .. } => { + detail.is_none() + } + UserInput::Skill { .. } => true, + UserInput::Mention { .. } => false, + }) +} + +fn submission_preview_lines( + submission: &QueuedSubmission, + width: u16, + locale: Locale, +) -> Vec> { + let content_width = usize::from(width.saturating_sub(4).max(1)); + let summary = submission_summary(submission, locale); + let mut wrapped = summary + .lines() + .flat_map(|line| { + wrap_hyperlink_line( + &HyperlinkLine::new(Line::styled( + line.to_string(), + Style::default().add_modifier(Modifier::ITALIC), + )), + content_width, + ) + }) + .map(|line| line.line) + .collect::>(); + if wrapped.is_empty() { + wrapped.push(Line::styled( + locale.not_set_label(), + Style::default().add_modifier(Modifier::ITALIC), + )); + } + + let overflow = wrapped.len() > MAX_LINES_PER_SUBMISSION; + wrapped.truncate(MAX_LINES_PER_SUBMISSION); + let mut lines = wrapped + .into_iter() + .enumerate() + .map(|(index, line)| { + let mut spans = vec![Span::styled( + if index == 0 { " ↳ " } else { " " }, + Style::default().fg(Color::DarkGray), + )]; + spans.extend(line.spans); + Line::from(spans) + }) + .collect::>(); + if overflow { + lines.push(Line::styled(" …", Style::default().fg(Color::DarkGray))); + } + lines +} + +fn submission_summary(submission: &QueuedSubmission, locale: Locale) -> String { + let mut parts = Vec::new(); + let mut image_count = 0usize; + for input in &submission.input { + match input { + UserInput::Text { text, .. } if !text.trim().is_empty() => { + parts.push(text.trim().to_string()); + } + UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, + UserInput::Skill { name, .. } => parts.push(format!("${name}")), + UserInput::Mention { name, .. } => parts.push(format!("@{name}")), + UserInput::Text { .. } => {} + } + } + if image_count > 0 { + parts.push(format!("[{} ×{image_count}]", locale.image_label())); + } + parts.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::line_truncation::line_width; + + fn submission(id: &str, input: Vec) -> QueuedSubmission { + QueuedSubmission { + id: id.to_string(), + input, + client_user_message_id: format!("client-{id}"), + } + } + + fn line_text(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + #[test] + fn preview_uses_canonical_multimodal_queue_and_caps_visible_items() { + let submissions = vec![ + submission( + "queue-1", + vec![ + UserInput::LocalImage { + detail: None, + path: "/tmp/one.png".to_string(), + }, + UserInput::Text { + text: "first follow-up".to_string(), + text_elements: Vec::new(), + }, + ], + ), + submission( + "queue-2", + vec![UserInput::Text { + text: "second follow-up".to_string(), + text_elements: Vec::new(), + }], + ), + submission( + "queue-3", + vec![UserInput::Text { + text: "third follow-up".to_string(), + text_elements: Vec::new(), + }], + ), + ]; + + let text = preview_lines(&submissions, 80, Locale::EnUs) + .iter() + .map(line_text) + .collect::>() + .join("\n"); + + assert!(text.contains("queued (3)"), "{text}"); + assert!(text.contains("first follow-up"), "{text}"); + assert!(text.contains("[image ×1]"), "{text}"); + assert!(text.contains("second follow-up"), "{text}"); + assert!(!text.contains("third follow-up"), "{text}"); + assert!(text.contains("… +1"), "{text}"); + } + + #[test] + fn narrow_preview_wraps_and_caps_each_submission() { + let submissions = vec![submission( + "queue-1", + vec![UserInput::Text { + text: "one two three four five six seven eight nine".to_string(), + text_elements: Vec::new(), + }], + )]; + + let lines = preview_lines(&submissions, 12, Locale::EnUs); + + assert_eq!(lines.len(), 5); + assert_eq!(line_text(&lines[3]), " …"); + assert!(line_text(lines.last().expect("edit hint")).contains("Alt+Up")); + assert!(lines.iter().all(|line| line_width(line) <= 12)); + } + + #[test] + fn edit_hint_requires_a_lossless_composer_projection() { + let local = submission( + "local", + vec![UserInput::LocalImage { + detail: None, + path: "/tmp/local.png".to_string(), + }], + ); + let remote = submission( + "remote", + vec![UserInput::Image { + detail: None, + url: "https://example.test/image.png".to_string(), + }], + ); + let skill = submission( + "skill", + vec![UserInput::Skill { + name: "review".to_string(), + path: "/skills/review/SKILL.md".to_string(), + }], + ); + let structured = submission( + "structured", + vec![ + UserInput::Text { + text: "first segment".to_string(), + text_elements: Vec::new(), + }, + UserInput::Text { + text: "second segment".to_string(), + text_elements: Vec::new(), + }, + ], + ); + + assert!(can_restore_submission(&local)); + assert!(can_restore_submission(&remote)); + assert!(can_restore_submission(&skill)); + assert!(!can_restore_submission(&structured)); + assert!(preview_lines(&[remote], 80, Locale::EnUs) + .iter() + .map(line_text) + .collect::() + .contains("Alt+Up")); + } + + #[test] + fn queue_header_and_image_label_cover_all_product_locales() { + let cases = [ + (Locale::ZhCn, "已排队", "图片"), + (Locale::ZhTw, "已排隊", "圖片"), + (Locale::EnUs, "queued", "image"), + (Locale::JaJp, "キューに追加済み", "画像"), + (Locale::KoKr, "대기열에 추가됨", "이미지"), + ]; + for (locale, queued, image) in cases { + let lines = preview_lines( + &[submission( + "queue-1", + vec![UserInput::Image { + detail: None, + url: "https://example.test/image.png".to_string(), + }], + )], + 80, + locale, + ); + let text = lines.iter().map(line_text).collect::>().join("\n"); + assert!(text.contains(queued), "{locale:?}: {text}"); + assert!(text.contains(image), "{locale:?}: {text}"); + } + } +} diff --git a/lime-rs/crates/tui/src/bottom_pane/render.rs b/lime-rs/crates/tui/src/bottom_pane/render.rs index bbceaf6a0..ec7501511 100644 --- a/lime-rs/crates/tui/src/bottom_pane/render.rs +++ b/lime-rs/crates/tui/src/bottom_pane/render.rs @@ -1,20 +1,34 @@ use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; use ratatui::Frame; +use std::time::Instant; use super::approval_overlay::ApprovalRequest; use super::mcp_server_elicitation; use super::request_user_input::render as request_user_input_render; +use super::selection_row_layout::{visible_item_window, MAX_POPUP_ROWS}; use super::{BottomPane, PendingInteraction}; use crate::locale::Locale; +use crate::style::{accent_style, attention_style, muted_style}; +use crate::wrapping::{word_wrap_line, RtOptions}; -pub(crate) fn desired_height_with_locale(pane: &BottomPane, locale: Locale) -> u16 { - let lines = lines_with_locale(pane, locale, usize::MAX) - .len() +pub(crate) fn desired_height_with_locale_for_width( + pane: &BottomPane, + locale: Locale, + width: u16, +) -> u16 { + // The interaction surface only has top/bottom borders, so its text width is the full + // terminal width. Measuring with a narrower width would under-allocate the pane and clip + // wrapped CJK/emoji content on narrow terminals. + let content_width = width.max(1); + let content = lines_with_locale(pane, locale, usize::from(content_width), Instant::now()); + let lines = Paragraph::new(content) + .wrap(Wrap { trim: false }) + .line_count(content_width) .saturating_add(2); - u16::try_from(lines).unwrap_or(u16::MAX).clamp(5, 14) + u16::try_from(lines).unwrap_or(u16::MAX).clamp(5, 18) } pub(crate) fn render_with_locale( @@ -25,9 +39,9 @@ pub(crate) fn render_with_locale( ) { let block = Block::default() .borders(Borders::TOP | Borders::BOTTOM) - .border_style(Style::default().fg(Color::Yellow)); + .border_style(attention_style()); let inner = block.inner(area); - let content = lines_with_locale(pane, locale, inner.width as usize); + let content = lines_with_locale(pane, locale, inner.width as usize, Instant::now()); frame.render_widget( Paragraph::new(content.clone()) .block(block) @@ -37,16 +51,21 @@ pub(crate) fn render_with_locale( match pane.current() { Some(PendingInteraction::UserInput(request)) => { - request_user_input_render::set_cursor_position(frame, inner, request, content.len()); + request_user_input_render::set_cursor_position(frame, inner, request, &content); } Some(PendingInteraction::McpElicitation(request)) => { - mcp_server_elicitation::set_cursor_position(frame, inner, request); + mcp_server_elicitation::set_cursor_position(frame, inner, request, &content); } _ => {} } } -fn lines_with_locale(pane: &BottomPane, locale: Locale, width: usize) -> Vec> { +fn lines_with_locale( + pane: &BottomPane, + locale: Locale, + width: usize, + now: Instant, +) -> Vec> { match pane.current() { Some(PendingInteraction::Approval(approval)) => { let (kind, details) = match &approval.request { @@ -82,21 +101,54 @@ fn lines_with_locale(pane: &BottomPane, locale: Locale, width: usize) -> Vec>(), + ) + .style(style) + }) + .collect::>() + }) + .collect() + } } Some(PendingInteraction::UserInput(request)) => { - request_user_input_render::lines_with_locale_with_width(request, locale, width) + request_user_input_render::lines_with_locale_with_width_at(request, locale, width, now) } Some(PendingInteraction::McpElicitation(request)) => { mcp_server_elicitation::lines_with_locale_with_width(request, locale, width) @@ -106,11 +158,93 @@ fn lines_with_locale(pane: &BottomPane, locale: Locale, width: usize) -> Vec Line<'static> { - let prefix = if selected { "> " } else { " " }; + let prefix = if selected { "› " } else { " " }; let style = if selected { - Style::default().fg(Color::Cyan) + accent_style() } else { Style::default() }; Line::styled(format!("{prefix}{label}"), style) } + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::{ + ServerRequest, ToolRequestUserInputOption, ToolRequestUserInputParams, + ToolRequestUserInputQuestion, + }; + use app_server_protocol::RequestId; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + + fn request_with_options(count: usize) -> ServerRequest { + ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(31), + params: ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "choice".to_string(), + header: "Next step".to_string(), + question: "Choose the next step for this task".to_string(), + is_other: false, + is_secret: false, + options: Some( + (0..count) + .map(|index| ToolRequestUserInputOption { + label: format!("Choice {index}"), + description: "A deliberately long description for narrow layout" + .to_string(), + }) + .collect(), + ), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + } + } + + #[test] + fn long_request_input_keeps_the_selected_option_inside_the_eight_row_window() { + let mut pane = BottomPane::default(); + pane.enqueue(request_with_options(12)) + .expect("queue request user input"); + for _ in 0..11 { + pane.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); + } + + let lines = lines_with_locale(&pane, Locale::EnUs, 80, Instant::now()); + let option_lines = lines + .iter() + .filter(|line| line.to_string().contains(". Choice ")) + .collect::>(); + assert_eq!(option_lines.len(), MAX_POPUP_ROWS); + assert!(lines + .iter() + .any(|line| line.to_string().contains("› 12. Choice 11"))); + assert!(!lines + .iter() + .any(|line| line.to_string().contains("1. Choice 0"))); + } + + #[test] + fn narrow_height_uses_the_same_width_as_rendered_content() { + let mut pane = BottomPane::default(); + pane.enqueue(request_with_options(2)) + .expect("queue request user input"); + let width = 12u16; + let content = lines_with_locale(&pane, Locale::EnUs, usize::from(width), Instant::now()); + let expected = Paragraph::new(content) + .wrap(Wrap { trim: false }) + .line_count(width) + .saturating_add(2) + .clamp(5, 18); + + assert_eq!( + desired_height_with_locale_for_width(&pane, Locale::EnUs, width), + u16::try_from(expected).expect("height fits in u16") + ); + } +} 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 1c415d135..aa076f89e 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 @@ -5,11 +5,35 @@ use app_server_protocol::protocol::v2::{ }; use app_server_protocol::RequestId; use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use std::time::{Duration, Instant}; use super::{AppServerResponse, ChatComposer, InputResult}; +use crate::width::display_width; pub(super) mod render; +const AUTO_RESOLUTION_HIDDEN_GRACE: Duration = Duration::from_secs(60); +const AUTO_RESOLUTION_VISIBLE_COUNTDOWN: Duration = Duration::from_secs(60); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AutoResolutionTiming { + Disabled, + HiddenGrace { remaining: Duration }, + VisibleCountdown { remaining: Duration }, + Due, +} + +fn format_auto_resolution_remaining(remaining: Duration) -> String { + let mut seconds = remaining.as_secs(); + if remaining.subsec_nanos() > 0 { + seconds = seconds.saturating_add(1); + } + if seconds < 60 { + return format!("{seconds}s"); + } + format!("{}m {:02}s", seconds / 60, seconds % 60) +} + #[derive(Debug)] pub(super) struct RequestUserInputOverlay { pub(super) id: RequestId, @@ -19,10 +43,24 @@ pub(super) struct RequestUserInputOverlay { pub(super) editing: bool, pub(super) composer: ChatComposer, answers: BTreeMap, + /// Stores one notes draft per question so navigation does not lose input. + question_drafts: Vec, + /// Stores option selection per question for reversible focus changes. + question_selections: Vec, + /// Stores the Options/Notes focus per question. + question_editing: Vec, + request_started_at: Instant, + auto_resolution_snoozed: bool, } impl RequestUserInputOverlay { pub(super) fn new(id: RequestId, params: ToolRequestUserInputParams) -> Self { + let question_count = params.questions.len(); + let question_editing = params + .questions + .iter() + .map(|question| question.options.as_ref().is_none_or(Vec::is_empty)) + .collect(); let editing = params .questions .first() @@ -36,13 +74,213 @@ impl RequestUserInputOverlay { editing, composer: ChatComposer::default(), answers: BTreeMap::new(), + question_drafts: vec![String::new(); question_count], + question_selections: vec![0; question_count], + question_editing, + request_started_at: Instant::now(), + auto_resolution_snoozed: false, } } + fn snooze_auto_resolution(&mut self) { + if !self.params.is_blocking { + self.auto_resolution_snoozed = true; + } + } + + fn auto_resolution_timing_at(&self, now: Instant) -> AutoResolutionTiming { + if self.params.is_blocking || self.auto_resolution_snoozed { + return AutoResolutionTiming::Disabled; + } + + let elapsed = now.saturating_duration_since(self.request_started_at); + if elapsed < AUTO_RESOLUTION_HIDDEN_GRACE { + return AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE.saturating_sub(elapsed), + }; + } + let visible_elapsed = elapsed.saturating_sub(AUTO_RESOLUTION_HIDDEN_GRACE); + if visible_elapsed < AUTO_RESOLUTION_VISIBLE_COUNTDOWN { + return AutoResolutionTiming::VisibleCountdown { + remaining: AUTO_RESOLUTION_VISIBLE_COUNTDOWN.saturating_sub(visible_elapsed), + }; + } + AutoResolutionTiming::Due + } + + pub(super) fn next_frame_delay(&self, now: Instant) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::Disabled => None, + AutoResolutionTiming::HiddenGrace { remaining } => Some(remaining), + AutoResolutionTiming::VisibleCountdown { remaining } => { + Some(remaining.min(Duration::from_secs(1))) + } + AutoResolutionTiming::Due => Some(Duration::ZERO), + } + } + + pub(super) fn pre_draw_tick(&mut self, now: Instant) -> Option { + if !matches!( + self.auto_resolution_timing_at(now), + AutoResolutionTiming::Due + ) { + return None; + } + Some(AppServerResponse::UserInput { + id: self.id.clone(), + response: ToolRequestUserInputResponse { + answers: BTreeMap::new(), + }, + }) + } + + pub(super) fn auto_resolution_countdown_text( + &self, + now: Instant, + locale: crate::locale::Locale, + ) -> Option { + match self.auto_resolution_timing_at(now) { + AutoResolutionTiming::VisibleCountdown { remaining } => { + Some(locale.auto_resolution_countdown(&format_auto_resolution_remaining(remaining))) + } + AutoResolutionTiming::Disabled + | AutoResolutionTiming::HiddenGrace { .. } + | AutoResolutionTiming::Due => None, + } + } + + pub(super) fn footer_hint(&self, locale: crate::locale::Locale) -> String { + let mut hints = Vec::with_capacity(5); + // The submit/cancel pair is the non-negotiable action set on narrow terminals. + // Secondary navigation may be clipped, but these two controls must remain visible. + hints.push(locale.request_submit_hint()); + hints.push(locale.request_cancel_hint()); + if self.has_options() && !self.editing { + hints.push(locale.request_select_hint()); + } + if self.has_options() { + hints.push(locale.request_notes_hint()); + } + if self.params.questions.len() > 1 { + hints.push(locale.request_question_nav_hint()); + } + hints.join(" · ") + } + + pub(super) fn footer_hint_for_width( + &self, + locale: crate::locale::Locale, + width: usize, + ) -> String { + let all = self.footer_hint(locale); + if display_width(&all) <= width { + return all; + } + + // Keep the primary actions first. At very narrow widths use key-only labels instead of + // truncating the pair in the middle and hiding the cancellation affordance. + let primary = format!( + "{} · {}", + locale.request_submit_hint(), + locale.request_cancel_hint() + ); + let compact_primary = [ + primary.as_str(), + "Enter · Esc", + "↵ · Esc", + "↵Esc", + "Esc", + "", + ] + .into_iter() + .find(|candidate| display_width(candidate) <= width) + .unwrap_or(""); + if compact_primary != primary { + return compact_primary.to_string(); + } + + let mut hints = vec![locale.request_submit_hint(), locale.request_cancel_hint()]; + let secondary = [ + (self.has_options() && !self.editing).then_some(locale.request_select_hint()), + self.has_options().then_some(locale.request_notes_hint()), + (self.params.questions.len() > 1).then_some(locale.request_question_nav_hint()), + ]; + for hint in secondary.into_iter().flatten() { + let candidate = hints.join(" · ") + " · " + hint; + if display_width(&candidate) <= width { + hints.push(hint); + } + } + hints.join(" · ") + } + + fn save_current_state(&mut self) { + if let Some(draft) = self.question_drafts.get_mut(self.question_index) { + *draft = self.composer.text().to_owned(); + } + if let Some(selection) = self.question_selections.get_mut(self.question_index) { + *selection = self.selected; + } + if let Some(editing) = self.question_editing.get_mut(self.question_index) { + *editing = self.editing; + } + } + + fn restore_current_state(&mut self) { + self.selected = self + .question_selections + .get(self.question_index) + .copied() + .unwrap_or(0); + let draft = self + .question_drafts + .get(self.question_index) + .cloned() + .unwrap_or_default(); + self.composer.replace(draft); + self.editing = self + .question_editing + .get(self.question_index) + .copied() + .unwrap_or_else(|| !self.has_options()); + } + + fn move_question(&mut self, next: bool) { + let count = self.params.questions.len(); + if count < 2 { + return; + } + self.save_current_state(); + self.question_index = if next { + (self.question_index + 1) % count + } else { + (self.question_index + count - 1) % count + }; + self.restore_current_state(); + } + + /// Move through the current question's choices with the same wrapping list semantics as + /// Codex. Notes editing remains a separate mode, so this is only called while options are + /// focused. + fn move_option(&mut self, next: bool) { + let count = self.option_count(); + if count == 0 { + return; + } + let selected = self.selected.min(count - 1); + self.selected = if next { + (selected + 1) % count + } else { + selected.checked_sub(1).unwrap_or(count - 1) + }; + self.save_current_state(); + } + pub(super) fn handle_key_event(&mut self, key: KeyEvent) -> Option { if self.params.questions.is_empty() { return Some(self.finish()); } + self.snooze_auto_resolution(); match key { key if key.kind == KeyEventKind::Press && key.modifiers.contains(KeyModifiers::CONTROL) @@ -51,20 +289,83 @@ impl RequestUserInputOverlay { Some(self.cancel()) } key if key.kind == KeyEventKind::Press => match key.code { + KeyCode::Esc if self.editing && self.has_options() => { + self.clear_notes_and_focus_options(); + None + } KeyCode::Esc => Some(self.cancel()), + KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.move_question(false); + None + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.move_question(true); + None + } + KeyCode::PageUp => { + self.move_question(false); + None + } + KeyCode::PageDown => { + self.move_question(true); + None + } + KeyCode::Left | KeyCode::Char('h') + if !self.editing && self.has_options() && key.modifiers.is_empty() => + { + self.move_question(false); + None + } + KeyCode::Right | KeyCode::Char('l') + if !self.editing && self.has_options() && key.modifiers.is_empty() => + { + self.move_question(true); + None + } + KeyCode::Tab if self.has_options() && self.editing => { + self.clear_notes_and_focus_options(); + None + } KeyCode::Tab if self.has_options() => { - self.editing = !self.editing; + self.restore_current_state(); + self.editing = true; + self.save_current_state(); None } KeyCode::Up if !self.editing => { - self.selected = self.selected.saturating_sub(1); + self.move_option(false); + None + } + KeyCode::Char('k') + if !self.editing + && (key.modifiers.is_empty() + || key.modifiers.contains(KeyModifiers::CONTROL)) => + { + self.move_option(false); None } KeyCode::Down if !self.editing => { - let count = self.option_count(); - if count > 0 { - self.selected = (self.selected + 1) % count; - } + self.move_option(true); + None + } + KeyCode::Char('j') + if !self.editing + && (key.modifiers.is_empty() + || key.modifiers.contains(KeyModifiers::CONTROL)) => + { + self.move_option(true); + None + } + KeyCode::Char(' ') + if !self.editing && self.has_options() && key.modifiers.is_empty() => + { + self.save_current_state(); + None + } + KeyCode::Backspace + if self.editing && self.has_options() && self.composer.is_empty() => + { + self.clear_notes_and_focus_options(); None } _ if self.editing && key.code == KeyCode::Enter && self.composer.is_empty() => { @@ -88,6 +389,7 @@ impl RequestUserInputOverlay { InputResult::Interrupt | InputResult::Quit => Some(self.cancel()), InputResult::Queued(text) => { self.composer.insert(&text); + self.save_current_state(); None } InputResult::None @@ -96,9 +398,20 @@ impl RequestUserInputOverlay { | InputResult::IncreaseEffort | InputResult::PreviousPermissions | InputResult::NextPermissions - | InputResult::OpenExternalEditor => None, + | InputResult::OpenExternalEditor + | InputResult::OpenAgentsOverview => { + self.save_current_state(); + None + } }, KeyCode::Enter => { + if self.selected == self.current_options().map_or(usize::MAX, <[_]>::len) + && self.other_option_enabled() + { + self.editing = true; + self.save_current_state(); + return None; + } let answer = self.selected_option_label().into_iter().collect(); self.commit(answer) } @@ -106,6 +419,7 @@ impl RequestUserInputOverlay { let index = ch.to_digit(10).unwrap_or_default() as usize - 1; if index < self.option_count() { self.selected = index; + self.save_current_state(); let answer = self.selected_option_label().into_iter().collect(); self.commit(answer) } else { @@ -119,6 +433,7 @@ impl RequestUserInputOverlay { } fn commit(&mut self, answers: Vec) -> Option { + self.save_current_state(); if let Some(question) = self.params.questions.get(self.question_index) { self.answers .insert(question.id.clone(), ToolRequestUserInputAnswer { answers }); @@ -127,12 +442,19 @@ impl RequestUserInputOverlay { return Some(self.finish()); } self.question_index += 1; - self.selected = 0; - self.composer = ChatComposer::default(); - self.editing = !self.has_options(); + self.restore_current_state(); None } + fn clear_notes_and_focus_options(&mut self) { + if let Some(draft) = self.question_drafts.get_mut(self.question_index) { + draft.clear(); + } + self.composer.replace(String::new()); + self.editing = false; + self.save_current_state(); + } + fn current_options( &self, ) -> Option<&[app_server_protocol::protocol::v2::ToolRequestUserInputOption]> { @@ -188,6 +510,16 @@ impl RequestUserInputOverlay { }, } } + + pub(super) fn handle_paste(&mut self, text: &str) { + if text.is_empty() { + return; + } + self.snooze_auto_resolution(); + self.editing = true; + self.composer.insert(text); + self.save_current_state(); + } } #[cfg(test)] @@ -231,6 +563,7 @@ mod tests { options: None, }, ], + is_blocking: true, auto_resolution_ms: None, }, ); @@ -255,6 +588,7 @@ mod tests { turn_id: "turn-1".to_string(), item_id: "question-1".to_string(), questions: Vec::new(), + is_blocking: true, auto_resolution_ms: None, }, ); @@ -282,6 +616,7 @@ mod tests { is_secret: true, options: None, }], + is_blocking: true, auto_resolution_ms: None, }, ); @@ -315,12 +650,13 @@ mod tests { description: "Continue immediately".to_string(), }]), }], + is_blocking: true, auto_resolution_ms: None, }, ); - request.handle_key_event(key(KeyCode::Tab)); - request.editing = true; + assert_eq!(request.handle_key_event(key(KeyCode::Tab)), None); + assert!(request.editing); request.composer.insert("keep logs"); let response = request.handle_key_event(key(KeyCode::Enter)); @@ -352,6 +688,7 @@ mod tests { description: "Continue immediately".to_string(), }]), }], + is_blocking: true, auto_resolution_ms: None, }, ); @@ -384,11 +721,14 @@ mod tests { description: "Continue immediately".to_string(), }]), }], + is_blocking: true, auto_resolution_ms: None, }, ); request.handle_key_event(key(KeyCode::Down)); + assert_eq!(request.handle_key_event(key(KeyCode::Enter)), None); + assert!(request.editing); let response = request.handle_key_event(key(KeyCode::Enter)); let Some(AppServerResponse::UserInput { response, .. }) = response else { @@ -396,4 +736,349 @@ mod tests { }; assert_eq!(response.answers["mode"].answers, ["Other"]); } + + #[test] + fn question_navigation_preserves_each_question_draft_and_selection() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(14), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ + ToolRequestUserInputQuestion { + id: "first".to_string(), + header: "First".to_string(), + question: "First note".to_string(), + is_other: false, + is_secret: false, + options: None, + }, + ToolRequestUserInputQuestion { + id: "second".to_string(), + header: "Second".to_string(), + question: "Second note".to_string(), + is_other: false, + is_secret: false, + options: None, + }, + ], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + request.composer.insert("draft one"); + assert_eq!( + request.handle_key_event(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)), + None + ); + assert_eq!(request.question_index, 1); + request.composer.insert("draft two"); + assert_eq!( + request.handle_key_event(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE)), + None + ); + assert_eq!(request.question_index, 0); + assert_eq!(request.composer.text(), "draft one"); + assert_eq!( + request.handle_key_event(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE)), + None + ); + assert_eq!(request.composer.text(), "draft two"); + } + + #[test] + fn other_enter_opens_notes_before_submitting_custom_answer() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(15), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + request.handle_key_event(key(KeyCode::Down)); + assert_eq!(request.handle_key_event(key(KeyCode::Enter)), None); + assert!(request.editing); + request.composer.insert("custom"); + let Some(AppServerResponse::UserInput { response, .. }) = + request.handle_key_event(key(KeyCode::Enter)) + else { + panic!("expected user input response"); + }; + assert_eq!( + response.answers["mode"].answers, + ["Other", "user_note: custom"] + ); + } + + #[test] + fn notes_focus_returns_to_options_without_submitting_on_escape_or_empty_backspace() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(16), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + request.handle_key_event(key(KeyCode::Tab)); + request.composer.insert("discarded"); + assert_eq!(request.handle_key_event(key(KeyCode::Esc)), None); + assert!(!request.editing); + assert!(request.composer.is_empty()); + + request.handle_key_event(key(KeyCode::Tab)); + assert_eq!(request.handle_key_event(key(KeyCode::Backspace)), None); + assert!(!request.editing); + assert!(request.composer.is_empty()); + } + + #[test] + fn options_typing_does_not_open_notes() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(17), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }, + ToolRequestUserInputOption { + label: "Safe".to_string(), + description: "Continue carefully".to_string(), + }, + ]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + assert_eq!(request.handle_key_event(key(KeyCode::Char('x'))), None); + assert!(!request.editing); + assert!(request.composer.is_empty()); + assert_eq!(request.handle_key_event(key(KeyCode::Char('j'))), None); + assert_eq!(request.selected, 1); + assert!(!request.editing); + assert_eq!(request.handle_key_event(key(KeyCode::Char('k'))), None); + assert_eq!(request.selected, 0); + assert_eq!(request.handle_key_event(key(KeyCode::Char(' '))), None); + assert!(!request.editing); + } + + #[test] + fn control_j_and_k_navigate_options_without_opening_notes() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(21), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }, + ToolRequestUserInputOption { + label: "Safe".to_string(), + description: "Continue carefully".to_string(), + }, + ]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + assert_eq!(request.selected, 0); + request.handle_key_event(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL)); + assert_eq!(request.selected, 1); + request.handle_key_event(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL)); + assert_eq!(request.selected, 0); + assert!(!request.editing); + } + + #[test] + fn option_navigation_wraps_at_both_ends() { + let mut request = RequestUserInputOverlay::new( + RequestId::Integer(22), + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }, + ToolRequestUserInputOption { + label: "Safe".to_string(), + description: "Continue carefully".to_string(), + }, + ]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + ); + + request.handle_key_event(key(KeyCode::Up)); + assert_eq!(request.selected, 1); + request.handle_key_event(key(KeyCode::Down)); + assert_eq!(request.selected, 0); + } + + fn non_blocking_request() -> ToolRequestUserInputParams { + ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose a mode".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }]), + }], + is_blocking: false, + auto_resolution_ms: None, + } + } + + #[test] + fn non_blocking_request_uses_hidden_grace_then_visible_countdown() { + let request = RequestUserInputOverlay::new(RequestId::Integer(18), non_blocking_request()); + let started = request.request_started_at; + + assert_eq!( + request.auto_resolution_timing_at(started), + AutoResolutionTiming::HiddenGrace { + remaining: AUTO_RESOLUTION_HIDDEN_GRACE + } + ); + let visible = started + AUTO_RESOLUTION_HIDDEN_GRACE; + assert!(request + .auto_resolution_countdown_text(visible, crate::locale::Locale::EnUs) + .is_some()); + assert_eq!( + request.next_frame_delay(visible), + Some(Duration::from_secs(1)) + ); + } + + #[test] + fn non_blocking_request_expires_with_empty_answers() { + let mut request = + RequestUserInputOverlay::new(RequestId::Integer(19), non_blocking_request()); + let due = request.request_started_at + + AUTO_RESOLUTION_HIDDEN_GRACE + + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + let Some(AppServerResponse::UserInput { response, .. }) = request.pre_draw_tick(due) else { + panic!("expected automatic user input response"); + }; + assert!(response.answers.is_empty()); + assert_eq!(request.next_frame_delay(due), Some(Duration::ZERO)); + } + + #[test] + fn non_blocking_request_is_snoozed_by_user_input() { + let mut request = + RequestUserInputOverlay::new(RequestId::Integer(20), non_blocking_request()); + let due = request.request_started_at + + AUTO_RESOLUTION_HIDDEN_GRACE + + AUTO_RESOLUTION_VISIBLE_COUNTDOWN; + assert_eq!(request.handle_key_event(key(KeyCode::Down)), None); + assert_eq!( + request.auto_resolution_timing_at(due), + AutoResolutionTiming::Disabled + ); + assert_eq!(request.pre_draw_tick(due), None); + } + + #[test] + fn narrow_footer_keeps_submit_and_cancel_before_secondary_hints() { + let request = RequestUserInputOverlay::new(RequestId::Integer(21), non_blocking_request()); + let footer = request.footer_hint_for_width(crate::locale::Locale::EnUs, 28); + + assert!(footer.contains("Enter submit")); + assert!(footer.contains("Esc cancel")); + assert!(crate::width::display_width(&footer) <= 28); + } + + #[test] + fn ultra_narrow_footer_keeps_cancel_action_visible_without_overflow() { + for locale in [ + crate::locale::Locale::ZhCn, + crate::locale::Locale::ZhTw, + crate::locale::Locale::EnUs, + crate::locale::Locale::JaJp, + crate::locale::Locale::KoKr, + ] { + let request = + RequestUserInputOverlay::new(RequestId::Integer(23), non_blocking_request()); + for width in [3, 4, 7, 12, 18] { + let footer = request.footer_hint_for_width(locale, width); + assert!( + crate::width::display_width(&footer) <= width, + "{locale:?} at {width}: {footer:?}" + ); + assert!(footer.contains("Esc"), "{locale:?} at {width}: {footer:?}"); + } + } + } } 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 3007fe419..78e0419c8 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,50 +1,102 @@ use ratatui::layout::{Position, Rect}; +use ratatui::style::Color; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Wrap}; use ratatui::Frame; +use std::time::Instant; use unicode_segmentation::UnicodeSegmentation; +use crate::bottom_pane::selection_row_layout::{visible_item_window, MAX_POPUP_ROWS}; use crate::line_truncation::line_width; use crate::locale::Locale; +use crate::style::{accent_style, muted_style}; use crate::width::display_width; +use crate::wrapping::{word_wrap_line, RtOptions}; use super::RequestUserInputOverlay; -pub(in crate::bottom_pane) fn lines_with_locale( - request: &RequestUserInputOverlay, - locale: Locale, -) -> Vec> { - lines_with_locale_unbounded(request, locale) -} - +#[allow(dead_code)] pub(in crate::bottom_pane) fn lines_with_locale_with_width( request: &RequestUserInputOverlay, locale: Locale, width: usize, ) -> Vec> { - let lines = if width == usize::MAX { - lines_with_locale(request, locale) - } else { - lines_with_locale_unbounded(request, locale) - }; + lines_with_locale_with_width_at(request, locale, width, Instant::now()) +} + +pub(in crate::bottom_pane) fn lines_with_locale_with_width_at( + request: &RequestUserInputOverlay, + locale: Locale, + width: usize, + now: Instant, +) -> Vec> { + let lines = lines_with_locale_unbounded(request, locale, now); if width == usize::MAX { lines } else { - lines - .into_iter() - .map(|line| truncate_line_word_boundary_with_ellipsis(line, width)) - .collect() + wrap_request_lines(lines, width.max(1), request.editing) } } +fn own_line(line: Line<'_>) -> Line<'static> { + let style = line.style; + let spans = line + .spans + .into_iter() + .map(|span| Span::styled(span.content.into_owned(), span.style)) + .collect::>(); + Line::from(spans).style(style) +} + +/// Wrap prompt/option/footer lines while keeping the editable value on one predictable row. +/// +/// The bottom pane's height is derived from this same projection, so a long Chinese or +/// Japanese label cannot push the footer (and therefore the cancel action) out of view. +fn wrap_request_lines( + lines: Vec>, + width: usize, + editing: bool, +) -> Vec> { + let input_index = editing.then(|| lines.len().saturating_sub(1)); + let mut wrapped = Vec::new(); + for (index, line) in lines.into_iter().enumerate() { + if Some(index) == input_index { + wrapped.push(truncate_line_word_boundary_with_ellipsis(line, width)); + continue; + } + // Keep each option on one row so a long description cannot consume the vertical + // budget and hide the selected action. The label remains visible; only the secondary + // description is ellipsized at narrow widths. + if is_option_line(&line) { + wrapped.push(truncate_line_word_boundary_with_ellipsis(line, width)); + continue; + } + let options = RtOptions::new(width).break_words(true); + wrapped.extend(word_wrap_line(&line, options).into_iter().map(own_line)); + } + wrapped +} + +fn is_option_line(line: &Line<'_>) -> bool { + let text = line.to_string(); + let text = text.trim_start_matches(['›', ' ']); + text.as_bytes().first().is_some_and(u8::is_ascii_digit) && text.contains(". ") +} + fn lines_with_locale_unbounded( request: &RequestUserInputOverlay, locale: Locale, + now: Instant, ) -> Vec> { let Some(question) = request.params.questions.get(request.question_index) else { return vec![Line::from(locale.no_questions())]; }; - let mut lines = vec![ + let mut lines = Vec::new(); + if let Some(countdown) = request.auto_resolution_countdown_text(now, locale) { + lines.push(Line::styled(countdown, Style::default().fg(Color::Red))); + } + lines.extend([ Line::styled( format!( "{} ({}/{})", @@ -55,23 +107,26 @@ fn lines_with_locale_unbounded( Style::default().add_modifier(Modifier::BOLD), ), Line::from(question.question.clone()), - ]; + ]); if let Some(options) = question .options .as_ref() .filter(|options| !options.is_empty()) { - lines.extend(options.iter().enumerate().map(|(index, option)| { - option_line( - !request.editing && index == request.selected, - format!("{} {}", option.label, option.description), - ) - })); - if question.is_other { - lines.push(option_line( - !request.editing && request.selected == options.len(), - locale.other_option().to_string(), - )); + let total = options.len() + usize::from(question.is_other); + let (start, end) = visible_item_window(request.selected, total, MAX_POPUP_ROWS); + for index in start..end { + if let Some(option) = options.get(index) { + lines.push(option_line( + !request.editing && index == request.selected, + format!("{}. {} {}", index + 1, option.label, option.description), + )); + } else if question.is_other && index == options.len() { + lines.push(option_line( + !request.editing && request.selected == options.len(), + format!("{}. {}", options.len() + 1, locale.other_option()), + )); + } } } if request.editing { @@ -81,14 +136,11 @@ fn lines_with_locale_unbounded( request.composer.text().to_string() }; lines.push(Line::from(vec![ - Span::styled("> ", Style::default().fg(ratatui::style::Color::Cyan)), + Span::styled("› ", accent_style()), Span::raw(value), ])); } else if question.options.is_some() { - lines.push(Line::styled( - locale.add_notes(), - Style::default().fg(ratatui::style::Color::DarkGray), - )); + lines.push(Line::styled(locale.add_notes(), muted_style())); } lines } @@ -97,23 +149,53 @@ pub(in crate::bottom_pane) fn set_cursor_position( frame: &mut Frame<'_>, inner: Rect, request: &RequestUserInputOverlay, - content_len: usize, + content: &[Line<'static>], ) { if !request.editing || inner.width == 0 || inner.height == 0 { return; } let value = &request.composer.text()[..request.composer.cursor()]; + let is_secret = request + .params + .questions + .get(request.question_index) + .is_some_and(|question| question.is_secret); + let display_value = if is_secret { + "*".repeat(value.chars().count()) + } else { + value.to_string() + }; + let input_prefix = format!("› {display_value}"); + let input_rows_before_cursor = Paragraph::new(Line::from(input_prefix)) + .wrap(Wrap { trim: false }) + .line_count(inner.width.max(1)); + let input_line_index = content.len().saturating_sub(1); + let preceding_rows = Paragraph::new(content[..input_line_index].to_vec()) + .wrap(Wrap { trim: false }) + .line_count(inner.width.max(1)); let value_width = request .params .questions .get(request.question_index) .filter(|question| question.is_secret) .map_or_else(|| display_width(value), |_| value.chars().count()); - let x = u16::try_from(value_width) - .unwrap_or(u16::MAX) - .saturating_add(2) - .min(inner.width.saturating_sub(1)); - let y = u16::try_from(content_len.saturating_sub(1)) + let current_line = value.rsplit('\n').next().unwrap_or(value); + let current_line_width = if is_secret { + current_line.chars().count() + } else { + display_width(current_line) + }; + let prefix_width = usize::from(!value.contains('\n')) * 2; + let x = u16::try_from(if value.contains('\n') { + current_line_width + } else { + value_width + }) + .unwrap_or(u16::MAX) + .saturating_add(prefix_width as u16) + .min(inner.width.saturating_sub(1)); + let input_row = preceding_rows.saturating_add(input_rows_before_cursor.saturating_sub(1)); + let y = u16::try_from(input_row) .unwrap_or(u16::MAX) .min(inner.height.saturating_sub(1)); frame.set_cursor_position(Position::new( @@ -123,9 +205,9 @@ pub(in crate::bottom_pane) fn set_cursor_position( } fn option_line(selected: bool, label: String) -> Line<'static> { - let prefix = if selected { "> " } else { " " }; + let prefix = if selected { "› " } else { " " }; let style = if selected { - Style::default().fg(ratatui::style::Color::Cyan) + accent_style() } else { Style::default() }; diff --git a/lime-rs/crates/tui/src/bottom_pane/selection_row_layout.rs b/lime-rs/crates/tui/src/bottom_pane/selection_row_layout.rs new file mode 100644 index 000000000..ca09db6c8 --- /dev/null +++ b/lime-rs/crates/tui/src/bottom_pane/selection_row_layout.rs @@ -0,0 +1,299 @@ +//! Codex-shaped selection-row layout shared by TUI pickers. +//! +//! This module owns display-only row composition: names, optional descriptions, disabled +//! reasons, and width-aware wrapping. Picker state and selection actions remain owned by their +//! respective App Server-backed surfaces. + +use std::borrow::Cow; + +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::{Line, Span}; +use unicode_segmentation::UnicodeSegmentation; + +use crate::line_truncation::line_width; +use crate::width::display_width; +use crate::wrapping::{word_wrap_line, RtOptions}; + +/// Maximum number of selectable rows shown by a selection popup. +/// +/// This is the same bounded viewport used by Codex's generic list picker. The +/// selected row may move the window, but the popup itself stays anchored. +pub(crate) const MAX_POPUP_ROWS: usize = 8; + +/// Center a popup while keeping its rectangle inside the available terminal area. +pub(crate) fn centered_popup(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect::new( + area.x.saturating_add(area.width.saturating_sub(width) / 2), + area.y + .saturating_add(area.height.saturating_sub(height) / 2), + width, + height, + ) +} + +/// Return the item window that keeps `selected` visible in a bounded popup. +pub(crate) fn visible_item_window( + selected: usize, + item_count: usize, + max_visible: usize, +) -> (usize, usize) { + if item_count == 0 || max_visible == 0 { + return (0, 0); + } + let visible = item_count.min(max_visible); + let selected = selected.min(item_count - 1); + let start = selected.saturating_sub(visible - 1); + let end = (start + visible).min(item_count); + (end.saturating_sub(visible), end) +} + +/// Controls whether descriptions stay in a column or move below labels when the description +/// column becomes too narrow to read. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum SelectionDescriptionLayout { + #[default] + Columns, + StackBelowWhenNarrow { + min_description_width: u16, + }, +} + +impl SelectionDescriptionLayout { + pub(crate) fn should_stack(self, width: u16, desc_col: usize) -> bool { + let Self::StackBelowWhenNarrow { + min_description_width, + } = self + else { + return false; + }; + width.saturating_sub(desc_col.min(usize::from(width)) as u16) < min_description_width + } +} + +/// A display-only selection row. Prefix spans are supplied by the caller. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct SelectionRow { + pub(crate) name: String, + pub(crate) name_prefix_spans: Vec>, + pub(crate) description: Option, + pub(crate) disabled_reason: Option, +} + +impl SelectionRow { + pub(crate) fn new( + name: impl Into, + description: Option, + name_prefix_spans: Vec>, + ) -> Self { + Self { + name: name.into(), + name_prefix_spans, + description, + disabled_reason: None, + } + } +} + +pub(crate) fn line_to_owned(line: Line<'_>) -> Line<'static> { + Line { + style: line.style, + alignment: line.alignment, + spans: line + .spans + .into_iter() + .map(|span| Span { + style: span.style, + content: Cow::Owned(span.content.into_owned()), + }) + .collect(), + } +} + +fn combined_description( + row: &SelectionRow, + description_layout: SelectionDescriptionLayout, +) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(description), Some(reason)) => Some(format!("{description} (disabled: {reason})")), + (Some(description), None) => Some(description.clone()), + (None, Some(reason)) + if matches!( + description_layout, + SelectionDescriptionLayout::StackBelowWhenNarrow { .. } + ) => + { + Some(reason.clone()) + } + (None, Some(reason)) => Some(format!("disabled: {reason}")), + (None, None) => None, + } +} + +fn stacked_description(row: &SelectionRow) -> Option { + match (&row.description, &row.disabled_reason) { + (Some(description), Some(reason)) => Some(format!("{description} (disabled: {reason})")), + (Some(description), None) => Some(description.clone()), + (None, Some(reason)) => Some(reason.clone()), + (None, None) => None, + } +} + +fn build_name_spans(row: &SelectionRow, name_limit: usize) -> Vec> { + let mut spans = Vec::with_capacity(row.name.len()); + let mut used_width = 0usize; + let mut truncated = false; + for grapheme in row.name.graphemes(true) { + let next_width = used_width.saturating_add(display_width(grapheme)); + if next_width > name_limit { + truncated = true; + break; + } + used_width = next_width; + spans.push(Span::raw(grapheme.to_string())); + } + if truncated { + spans.push(Span::raw("…")); + } + if row.disabled_reason.is_some() { + spans.push(" (disabled)".dim()); + } + spans +} + +/// Build a full row with the description beginning at desc_col. +pub(crate) fn build_full_line( + row: &SelectionRow, + desc_col: usize, + description_layout: SelectionDescriptionLayout, +) -> Line<'static> { + let description = combined_description(row, description_layout); + let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())); + let name_limit = description + .as_ref() + .map(|_| desc_col.saturating_sub(2).saturating_sub(prefix_width)) + .unwrap_or(usize::MAX); + let name_spans = build_name_spans(row, name_limit); + let name_width = prefix_width + line_width(&Line::from(name_spans.clone())); + + let mut spans = row.name_prefix_spans.clone(); + spans.extend(name_spans); + if let Some(description) = description { + let gap = desc_col.saturating_sub(name_width); + if gap > 0 { + spans.push(Span::raw(" ".repeat(gap))); + } + spans.push(Span::raw(description).dim()); + } + Line::from(spans) +} + +/// Render a row as a label followed by an indented, wrapped description. +pub(crate) fn wrap_stacked_row(row: &SelectionRow, width: u16) -> Vec> { + let width = width.max(1); + let prefix_width = line_width(&Line::from(row.name_prefix_spans.clone())) + .min(width.saturating_sub(1) as usize); + let indent = " ".repeat(prefix_width); + + let mut label_spans = row.name_prefix_spans.clone(); + label_spans.extend(build_name_spans(row, usize::MAX)); + let label = Line::from(label_spans); + let label_options = RtOptions::new(width as usize) + .initial_indent(Line::default()) + .subsequent_indent(Line::from(indent.clone())); + let mut lines = word_wrap_line(&label, label_options) + .into_iter() + .map(line_to_owned) + .collect::>(); + + if let Some(description) = stacked_description(row) { + let description_options = RtOptions::new(width as usize) + .initial_indent(Line::from(indent.clone())) + .subsequent_indent(Line::from(indent)); + lines.extend( + word_wrap_line(&Line::from(description.dim()), description_options) + .into_iter() + .map(line_to_owned), + ); + } + lines +} + +/// Build wrapped rows while preserving the Codex narrow-width stack fallback. +pub(crate) fn wrap_row( + row: &SelectionRow, + desc_col: usize, + width: u16, + description_layout: SelectionDescriptionLayout, +) -> Vec> { + if description_layout.should_stack(width, desc_col) { + return wrap_stacked_row(row, width); + } + let full_line = build_full_line(row, desc_col, description_layout); + let options = RtOptions::new(width.max(1) as usize) + .initial_indent(Line::default()) + .subsequent_indent(Line::default()); + word_wrap_line(&full_line, options) + .into_iter() + .map(line_to_owned) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn centered_popup_is_clamped_and_keeps_terminal_anchor() { + let area = Rect::new(4, 3, 20, 10); + assert_eq!(centered_popup(area, 12, 6), Rect::new(8, 5, 12, 6)); + assert_eq!(centered_popup(area, 80, 80), area); + } + + #[test] + fn visible_item_window_keeps_selection_inside_eight_row_viewport() { + assert_eq!(visible_item_window(0, 20, MAX_POPUP_ROWS), (0, 8)); + assert_eq!(visible_item_window(7, 20, MAX_POPUP_ROWS), (0, 8)); + assert_eq!(visible_item_window(8, 20, MAX_POPUP_ROWS), (1, 9)); + assert_eq!(visible_item_window(19, 20, MAX_POPUP_ROWS), (12, 20)); + assert_eq!(visible_item_window(0, 0, MAX_POPUP_ROWS), (0, 0)); + } + + #[test] + fn wide_rows_align_description_with_display_width() { + let row = SelectionRow::new("模型🙂", Some("provider".to_string()), Vec::new()); + let line = build_full_line(&row, 12, SelectionDescriptionLayout::Columns); + assert!(line.to_string().contains("provider")); + assert!(line_width(&line) >= 20); + } + + #[test] + fn narrow_rows_stack_description_without_splitting_graphemes() { + let row = SelectionRow::new("模型🙂", Some("提供方".to_string()), Vec::new()); + let lines = wrap_row( + &row, + 6, + 12, + SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: 8, + }, + ); + assert!(lines.len() >= 2); + assert!(lines.iter().any(|line| line.to_string().contains("🙂"))); + assert!(lines.iter().all(|line| line_width(line) <= 12)); + } + + #[test] + fn disabled_reason_is_visible_and_width_bounded() { + let row = SelectionRow { + name: "option".to_string(), + description: None, + disabled_reason: Some("later".to_string()), + ..SelectionRow::default() + }; + let line = build_full_line(&row, 8, SelectionDescriptionLayout::Columns); + assert!(line.to_string().contains("disabled: later")); + } +} diff --git a/lime-rs/crates/tui/src/command_popup.rs b/lime-rs/crates/tui/src/command_popup.rs index a9478d6b2..4a526c7e0 100644 --- a/lime-rs/crates/tui/src/command_popup.rs +++ b/lime-rs/crates/tui/src/command_popup.rs @@ -1,219 +1,7 @@ -use crossterm::event::{Event, KeyCode, KeyEventKind}; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Clear, Paragraph}; -use ratatui::Frame; +//! Compatibility re-export for the moved bottom-pane command popup owner. +//! +//! New consumers must import `bottom_pane::command_popup`; this module remains +//! only until all downstream test and integration surfaces have converged. -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::locale::Locale; -use crate::slash_command::{command_filter, SlashCommand}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum CommandPopupAction { - Pass, - Consumed, - Cancel, - Complete(SlashCommand), - Execute(SlashCommand), -} - -#[derive(Debug, Clone)] -pub(crate) struct CommandPopup { - filter: String, - selected: usize, -} - -impl CommandPopup { - pub(crate) fn for_composer(text: &str) -> Option { - let filter = command_filter(text)?.to_ascii_lowercase(); - let popup = Self { - filter, - selected: 0, - }; - (!popup.matches().is_empty()).then_some(popup) - } - - pub(crate) fn update(&mut self, text: &str) -> bool { - let Some(filter) = command_filter(text) else { - return false; - }; - self.filter = filter.to_ascii_lowercase(); - let matches = self.matches(); - if matches.is_empty() { - return false; - } - self.selected = self.selected.min(matches.len() - 1); - true - } - - pub(crate) fn commands(&self) -> Vec { - self.matches() - } - - pub(crate) fn selected(&self) -> Option { - self.matches().get(self.selected).copied() - } - - pub(crate) fn handle_event(&mut self, event: &Event) -> CommandPopupAction { - let Event::Key(key) = event else { - return CommandPopupAction::Pass; - }; - if key.kind != KeyEventKind::Press { - return CommandPopupAction::Pass; - } - match key.code { - KeyCode::Up => { - let len = self.matches().len(); - if len > 0 { - self.selected = self.selected.checked_sub(1).unwrap_or(len - 1); - } - CommandPopupAction::Consumed - } - KeyCode::Down => { - let len = self.matches().len(); - if len > 0 { - self.selected = (self.selected + 1) % len; - } - CommandPopupAction::Consumed - } - KeyCode::Esc => CommandPopupAction::Cancel, - KeyCode::Tab => self - .selected() - .map(CommandPopupAction::Complete) - .unwrap_or(CommandPopupAction::Consumed), - KeyCode::Enter => self - .selected() - .map(|command| { - if command.requires_argument() { - CommandPopupAction::Complete(command) - } else { - CommandPopupAction::Execute(command) - } - }) - .unwrap_or(CommandPopupAction::Consumed), - _ => CommandPopupAction::Pass, - } - } - - fn matches(&self) -> Vec { - SlashCommand::ALL - .into_iter() - .filter(|command| command.command().starts_with(&self.filter)) - .collect() - } -} - -pub(crate) fn render( - frame: &mut Frame<'_>, - composer_area: Rect, - popup: &CommandPopup, - locale: Locale, -) { - let commands = popup.commands(); - if commands.is_empty() || composer_area.y == 0 || composer_area.width == 0 { - return; - } - let height = u16::try_from(commands.len()) - .unwrap_or(u16::MAX) - .min(composer_area.y); - let area = Rect::new( - composer_area.x, - composer_area.y.saturating_sub(height), - composer_area.width, - height, - ); - let lines = commands - .into_iter() - .enumerate() - .map(|(index, command)| { - let selected = index == popup.selected; - let marker = if selected { "> " } else { " " }; - let command_style = if selected { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().add_modifier(Modifier::BOLD) - }; - truncate_line_with_ellipsis_if_overflow( - Line::from(vec![ - Span::styled(marker, command_style), - Span::styled(format!("/{}", command.command()), command_style), - Span::raw(" "), - Span::styled( - command.description(locale), - Style::default().add_modifier(Modifier::DIM), - ), - ]), - usize::from(area.width), - ) - }) - .collect::>(); - frame.render_widget(Clear, area); - frame.render_widget(Paragraph::new(lines), area); -} - -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyEvent, KeyModifiers}; - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - #[test] - fn filters_by_prefix_and_wraps_selection() { - let mut popup = CommandPopup::for_composer("/").expect("popup"); - assert_eq!(popup.selected(), Some(SlashCommand::Model)); - assert_eq!( - popup.handle_event(&Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE,))), - CommandPopupAction::Consumed - ); - assert_eq!(popup.selected(), SlashCommand::ALL.last().copied()); - assert!(popup.update("/per")); - assert_eq!(popup.commands(), vec![SlashCommand::Permissions]); - assert_eq!(popup.selected(), Some(SlashCommand::Permissions)); - assert!(!popup.update("/unknown")); - } - - #[test] - fn enter_executes_immediate_commands_and_completes_argument_commands() { - let event = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - let mut model = CommandPopup::for_composer("/m").expect("model popup"); - assert_eq!( - model.handle_event(&event), - CommandPopupAction::Execute(SlashCommand::Model) - ); - let mut effort = CommandPopup::for_composer("/e").expect("effort popup"); - assert_eq!( - effort.handle_event(&event), - CommandPopupAction::Complete(SlashCommand::Effort) - ); - } - - #[test] - fn test_backend_renders_commands_and_localized_descriptions() { - let popup = CommandPopup::for_composer("/").expect("popup"); - let mut terminal = Terminal::new(TestBackend::new(72, 10)).expect("terminal"); - terminal - .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) - .map(|y| { - (0..buffer.area.width) - .map(|x| buffer[(x, y)].symbol()) - .collect::() - }) - .collect::>() - .join("\n"); - let compact = text - .chars() - .filter(|character| !character.is_whitespace()) - .collect::(); - - assert!(text.contains("/model")); - assert!(compact.contains("选择模型"), "{text}"); - assert!(text.contains("/copy")); - } -} +#[allow(unused_imports)] +pub(crate) use crate::bottom_pane::command_popup::{render, CommandPopup, CommandPopupAction}; diff --git a/lime-rs/crates/tui/src/entry.rs b/lime-rs/crates/tui/src/entry.rs index 3575d8dc0..f2937b700 100644 --- a/lime-rs/crates/tui/src/entry.rs +++ b/lime-rs/crates/tui/src/entry.rs @@ -8,6 +8,10 @@ use crate::exec_cell::{output_lines, CommandOutput, OutputLinesParams}; use crate::locale::Locale; use crate::markdown_render; use crate::projection::{EntryKind, EntryStatus, TranscriptEntry}; +use crate::style::{ + accent_style, attention_style, failure_style, muted_style, status_style, user_message_style, + StatusTone, +}; use crate::terminal_hyperlinks::{prefix_hyperlink_lines, HyperlinkLine}; #[cfg(test)] @@ -41,17 +45,20 @@ pub(crate) fn hyperlink_lines_with_locale( if rendered.is_empty() { rendered.push(HyperlinkLine::default()); } - let status_suffix = entry - .status - .map(|status| format!(" [{}]", locale.status(status.label()))); rendered = prefix_hyperlink_lines( rendered, Span::styled(prefix, prefix_style), Span::styled(" ", prefix_style), ); - if let Some(suffix) = status_suffix { + if let Some(status) = entry.status { if let Some(line) = rendered.first_mut() { - line.push_span(Span::styled(suffix, text_style), None); + line.push_span( + Span::styled( + format!(" [{}]", locale.status(status.label())), + entry_status_style(status), + ), + None, + ); } } rendered.extend(entry.summary.iter().map(|detail| { @@ -73,14 +80,14 @@ pub(crate) fn hyperlink_lines_with_locale( 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( - entry.kind, - prefix, - prefix_style, - text_style, - &first, - ))]; + let mut first_line = format_line(entry.kind, prefix, prefix_style, text_style, &first); + if let Some(status) = entry.status { + first_line.spans.push(Span::styled( + format!(" [{}]", locale.status(status.label())), + entry_status_style(status), + )); + } + let mut lines = vec![HyperlinkLine::new(first_line)]; if entry.kind == EntryKind::Command { let output = CommandOutput::from_lines(source, locale); let rendered_output = output_lines( @@ -144,25 +151,19 @@ fn formatted_summary(kind: EntryKind, detail: &str, locale: Locale) -> String { format!("- {}", locale.detail(detail)) } -fn with_status_suffix(text: &str, status: Option, locale: Locale) -> String { - status - .map(|status| format!("{text} [{}]", locale.status(status.label()))) - .unwrap_or_else(|| text.to_string()) +fn entry_status_style(status: EntryStatus) -> Style { + match status { + EntryStatus::Completed => status_style(StatusTone::Success), + EntryStatus::Running => attention_style(), + EntryStatus::Failed | EntryStatus::Declined | EntryStatus::Interrupted => failure_style(), + } } fn styles(kind: EntryKind) -> (&'static str, Style, Style) { match kind { - EntryKind::User => ( - "> ", - Style::default().fg(Color::Cyan), - Style::default().fg(Color::Cyan), - ), + EntryKind::User => ("› ", accent_style(), user_message_style()), EntryKind::Assistant => (" ", Style::default(), Style::default()), - EntryKind::Reasoning => ( - "· ", - Style::default().fg(Color::DarkGray), - Style::default().fg(Color::DarkGray), - ), + EntryKind::Reasoning => ("· ", muted_style(), muted_style()), EntryKind::Command => ( "$ ", Style::default().fg(Color::Yellow), @@ -172,7 +173,7 @@ fn styles(kind: EntryKind) -> (&'static str, Style, Style) { EntryKind::Mcp => ("@ ", Style::default().fg(Color::Magenta), Style::default()), EntryKind::Plan => ( "• ", - Style::default().fg(Color::Cyan), + accent_style(), Style::default().add_modifier(Modifier::BOLD), ), EntryKind::MultiAgent => ( @@ -181,17 +182,13 @@ fn styles(kind: EntryKind) -> (&'static str, Style, Style) { Style::default(), ), EntryKind::Tool => ("• ", Style::default().fg(Color::Yellow), Style::default()), - EntryKind::System => ( - "! ", - Style::default().fg(Color::Red), - Style::default().fg(Color::Red), - ), + EntryKind::System => ("! ", failure_style(), failure_style()), } } fn continuation_style(kind: EntryKind, base_style: Style) -> Style { if kind == EntryKind::Command { - Style::default().fg(Color::DarkGray) + muted_style() } else { base_style } @@ -208,13 +205,11 @@ fn format_line( EntryKind::Patch if text.starts_with('+') => Style::default().fg(Color::Green), EntryKind::Patch if text.starts_with('-') => Style::default().fg(Color::Red), EntryKind::Patch if text.starts_with("@@") => Style::default().fg(Color::Cyan), - EntryKind::Plan if text.starts_with("[x]") => Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::CROSSED_OUT), - EntryKind::Plan if text.starts_with("[~]") => Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - EntryKind::Plan if text.starts_with("[ ]") => Style::default().fg(Color::DarkGray), + EntryKind::Plan if text.starts_with("[x]") => { + muted_style().add_modifier(Modifier::CROSSED_OUT) + } + EntryKind::Plan if text.starts_with("[~]") => accent_style(), + EntryKind::Plan if text.starts_with("[ ]") => muted_style(), _ => base_style, }; Line::from(vec![ @@ -247,7 +242,10 @@ mod tests { let multi_agent = lines(&entry(EntryKind::MultiAgent, "SpawnAgent [InProgress]")); assert_eq!(command[0].spans[0].content.as_ref(), "$ "); - assert!(command[0].spans[1].content.contains("[running]")); + assert!(command[0] + .spans + .iter() + .any(|span| span.content.contains("[running]"))); assert_eq!(patch[0].spans[0].content.as_ref(), "Δ "); assert_eq!(mcp[0].spans[0].content.as_ref(), "@ "); assert_eq!(plan[0].spans[0].content.as_ref(), "• "); @@ -256,7 +254,10 @@ mod tests { .spans .iter() .any(|span| span.style.fg == Some(Color::Green))); - assert_eq!(command[1].spans[1].style.fg, Some(Color::DarkGray)); + assert!(command[1].spans[1] + .style + .add_modifier + .contains(Modifier::DIM)); } #[test] @@ -323,8 +324,39 @@ mod tests { .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)); + assert!(running[0].spans[1] + .style + .add_modifier + .contains(Modifier::BOLD)); + assert!(pending[0].spans[1] + .style + .add_modifier + .contains(Modifier::DIM)); + } + + #[test] + fn lifecycle_status_suffixes_use_the_shared_semantic_tones() { + crate::terminal_palette::with_test_default_colors( + crate::terminal_probe::DefaultColors { + fg: (255, 255, 255), + bg: (0, 0, 0), + }, + || { + for (status, expected) in [ + (EntryStatus::Completed, Color::Green), + (EntryStatus::Running, Color::Yellow), + (EntryStatus::Failed, Color::Red), + ] { + let mut item = entry(EntryKind::Mcp, "server.tool"); + item.status = Some(status); + let rendered = lines(&item); + assert_eq!( + rendered[0].spans.last().and_then(|span| span.style.fg), + Some(expected) + ); + } + }, + ); } #[test] @@ -357,7 +389,7 @@ mod tests { let rendered = lines(&entry(EntryKind::Reasoning, "**Inspecting** `src/lib.rs`")); assert!(rendered.iter().flat_map(|line| &line.spans).any(|span| { - span.style.fg == Some(Color::DarkGray) + span.style.add_modifier.contains(Modifier::DIM) && span.style.add_modifier.contains(Modifier::BOLD) })); assert!(rendered diff --git a/lime-rs/crates/tui/src/history_cell/mcp.rs b/lime-rs/crates/tui/src/history_cell/mcp.rs index a91e620a9..73dcb4219 100644 --- a/lime-rs/crates/tui/src/history_cell/mcp.rs +++ b/lime-rs/crates/tui/src/history_cell/mcp.rs @@ -1,6 +1,12 @@ //! MCP tool-call history cells. use super::*; +use crate::locale::Locale; +use app_server_protocol::protocol::v2::{ + McpAuthStatus, McpServerConnectionStatus, McpServerStatus, McpServerStatusDetail, +}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; #[derive(Debug, Clone)] pub(crate) struct McpInvocation { @@ -70,3 +76,277 @@ impl HistoryCell for McpInventoryLoadingCell { self.display_lines(u16::MAX) } } + +/// Convert the App Server-owned MCP inventory into bounded pager lines. +/// +/// Only status/auth/tool names are rendered here. The TUI does not inspect client-local config or +/// provider-specific payloads, so unknown fields remain outside the presentation contract. +pub(crate) fn mcp_inventory_lines( + statuses: &[McpServerStatus], + detail: McpServerStatusDetail, + locale: Locale, +) -> Vec> { + let mut lines = vec![ + Line::from("/mcp"), + Line::from(""), + Line::from(Span::styled( + format!("🔌 {}", locale.mcp_inventory_title()), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + ]; + let mut statuses = statuses.iter().collect::>(); + statuses.sort_by(|left, right| left.name.cmp(&right.name)); + if statuses.is_empty() { + lines.push(Line::from(locale.mcp_no_servers())); + return lines; + } + if matches!(detail, McpServerStatusDetail::Full) + && statuses.iter().all(|status| status.tools.is_empty()) + { + lines.push(Line::from(locale.mcp_no_tools_available())); + lines.push(Line::from("")); + } + for status in statuses { + let state = locale.mcp_status(mcp_connection_status_label( + status.runtime_status, + status.auth_status, + )); + let count = status.tools.len(); + lines.push(Line::from(format!( + " • {}: {} ({} {})", + status.name, + state, + count, + locale.mcp_tool_unit(count), + ))); + if matches!(detail, McpServerStatusDetail::ToolsAndAuthOnly) { + continue; + } + let auth = locale.mcp_auth_status(mcp_auth_status_label(status.auth_status)); + lines.push(Line::from(format!( + " • {}: {auth}", + locale.mcp_auth_label(), + ))); + + let mut names = status.tools.keys().cloned().collect::>(); + names.sort(); + let tools = if names.is_empty() { + locale.mcp_none().to_string() + } else { + names.join(", ") + }; + lines.push(Line::from(format!( + " • {}: {tools}", + locale.mcp_tools_label() + ))); + + if status.resources.is_empty() { + lines.push(Line::from(format!( + " • {}: {}", + locale.mcp_resources_label(), + locale.mcp_none() + ))); + } else { + let values = status + .resources + .iter() + .map(|resource| { + format!( + "{} ({})", + resource.title.as_deref().unwrap_or(&resource.name), + resource.uri + ) + }) + .collect::>() + .join(", "); + lines.push(Line::from(format!( + " • {}: {values}", + locale.mcp_resources_label() + ))); + } + + if status.resource_templates.is_empty() { + lines.push(Line::from(format!( + " • {}: {}", + locale.mcp_resource_templates_label(), + locale.mcp_none() + ))); + } else { + let values = status + .resource_templates + .iter() + .map(|template| { + format!( + "{} ({})", + template.title.as_deref().unwrap_or(&template.name), + template.uri_template + ) + }) + .collect::>() + .join(", "); + lines.push(Line::from(format!( + " • {}: {values}", + locale.mcp_resource_templates_label() + ))); + } + lines.push(Line::from("")); + } + if matches!(detail, McpServerStatusDetail::ToolsAndAuthOnly) { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + locale.mcp_use_verbose(), + Style::default().add_modifier(Modifier::DIM), + ))); + } + lines +} + +fn mcp_connection_status_label( + status: Option, + auth_status: McpAuthStatus, +) -> &'static str { + match status { + Some(McpServerConnectionStatus::Connected) => "connected", + Some(McpServerConnectionStatus::Starting) => "starting", + Some(McpServerConnectionStatus::AuthenticationRequired) => "authentication required", + Some(McpServerConnectionStatus::Failed) => "failed", + Some(McpServerConnectionStatus::NotStarted) => "not started", + Some(McpServerConnectionStatus::Disabled) => "disabled", + Some(McpServerConnectionStatus::Cancelled) => "cancelled", + None if matches!(auth_status, McpAuthStatus::NotLoggedIn) => "authentication required", + None => "unknown", + } +} + +fn mcp_auth_status_label(status: McpAuthStatus) -> &'static str { + match status { + McpAuthStatus::Unknown => "unknown", + McpAuthStatus::Unsupported => "unsupported", + McpAuthStatus::NotLoggedIn => "not logged in", + McpAuthStatus::BearerToken => "bearer token", + McpAuthStatus::OAuth => "OAuth", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use app_server_protocol::protocol::v2::McpTool; + use std::collections::HashMap; + + fn line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() + } + + fn status(name: &str, tool: &str) -> McpServerStatus { + let mut tools = HashMap::new(); + tools.insert( + tool.to_string(), + McpTool { + name: tool.to_string(), + title: None, + description: None, + input_schema: serde_json::json!({}), + output_schema: None, + annotations: None, + icons: None, + meta: None, + }, + ); + McpServerStatus { + name: name.to_string(), + runtime_status: Some(McpServerConnectionStatus::Connected), + plugin_id: None, + server_info: None, + tools, + resources: Vec::new(), + resource_templates: Vec::new(), + auth_status: McpAuthStatus::BearerToken, + } + } + + #[test] + fn inventory_is_sorted_and_full_detail_includes_tool_counts_and_names() { + let lines = mcp_inventory_lines( + &[status("zeta", "z-tool"), status("alpha", "a-tool")], + McpServerStatusDetail::Full, + Locale::EnUs, + ); + let text = lines.iter().map(line_text).collect::>(); + assert_eq!(text[4], " • alpha: connected (1 tool)"); + assert_eq!(text[5], " • Auth: Bearer token"); + assert_eq!(text[6], " • Tools: a-tool"); + assert_eq!(text[7], " • Resources: (none)"); + assert_eq!(text[8], " • Resource templates: (none)"); + assert!(text[10].starts_with(" • zeta: connected")); + } + + #[test] + fn compact_inventory_matches_codex_summary_shape() { + let lines = mcp_inventory_lines( + &[status("alpha", "a-tool")], + McpServerStatusDetail::ToolsAndAuthOnly, + Locale::EnUs, + ); + let text = lines.iter().map(line_text).collect::>(); + assert_eq!(text[4], " • alpha: connected (1 tool)"); + assert!(text.iter().all(|line| !line.contains("Auth:"))); + assert_eq!(text[6], "Use /mcp verbose for tools and resources."); + } + + #[test] + fn full_inventory_uses_codex_empty_tool_label() { + let mut empty = status("alpha", "a-tool"); + empty.tools.clear(); + let lines = mcp_inventory_lines(&[empty], McpServerStatusDetail::Full, Locale::EnUs); + let text = lines.iter().map(line_text).collect::>(); + assert_eq!(text[8], " • Tools: (none)"); + } + + #[test] + fn full_inventory_preserves_resource_order_from_app_server() { + let mut server = status("alpha", "a-tool"); + server.resources = vec![ + app_server_protocol::protocol::v2::McpResource { + annotations: None, + name: "zeta".to_string(), + title: None, + uri: "file:///zeta".to_string(), + description: None, + mime_type: None, + size: None, + icons: None, + meta: None, + }, + app_server_protocol::protocol::v2::McpResource { + annotations: None, + name: "alpha".to_string(), + title: None, + uri: "file:///alpha".to_string(), + description: None, + mime_type: None, + size: None, + icons: None, + meta: None, + }, + ]; + let lines = mcp_inventory_lines(&[server], McpServerStatusDetail::Full, Locale::EnUs); + let text = lines.iter().map(line_text).collect::>(); + assert_eq!(text[5], " • Auth: Bearer token"); + assert_eq!( + text[7], + " • Resources: zeta (file:///zeta), alpha (file:///alpha)" + ); + } + + #[test] + fn inventory_empty_state_is_localized() { + let lines = mcp_inventory_lines(&[], McpServerStatusDetail::ToolsAndAuthOnly, Locale::ZhCn); + let text = lines.iter().map(line_text).collect::>(); + assert_eq!(text[4], "未配置 MCP 服务器。"); + } +} diff --git a/lime-rs/crates/tui/src/history_cell/mod.rs b/lime-rs/crates/tui/src/history_cell/mod.rs index c3b806ac1..e9400a0ab 100644 --- a/lime-rs/crates/tui/src/history_cell/mod.rs +++ b/lime-rs/crates/tui/src/history_cell/mod.rs @@ -229,7 +229,7 @@ mod tests { PathBuf::from("/workspace"), ); let lines = cell.display_lines(80); - assert_eq!(lines[0].spans[0].content, "> "); + assert_eq!(lines[0].spans[0].content, "› "); assert_eq!(lines[1].spans[0].content, " "); assert!(lines.iter().any(|line| { line.spans diff --git a/lime-rs/crates/tui/src/history_cell/session.rs b/lime-rs/crates/tui/src/history_cell/session.rs index 0d12a6161..429d1f025 100644 --- a/lime-rs/crates/tui/src/history_cell/session.rs +++ b/lime-rs/crates/tui/src/history_cell/session.rs @@ -1,6 +1,15 @@ //! Session header history cells. use super::*; +use crate::line_truncation::{line_width, truncate_line_with_ellipsis_if_overflow}; +use crate::locale::Locale; +use crate::style::{accent_style, attention_style, muted_style}; +use crate::text_formatting::center_truncate_path; +use crate::width::display_width; +use ratatui::style::Style; +use ratatui::text::Span; + +const SESSION_HEADER_MAX_INNER_WIDTH: usize = 56; #[derive(Debug)] pub struct SessionInfoCell(CompositeHistoryCell); @@ -25,33 +34,245 @@ impl HistoryCell for SessionInfoCell { pub(crate) struct SessionHeaderHistoryCell { pub(crate) version: String, pub(crate) model: String, + pub(crate) reasoning_effort: Option, + pub(crate) permissions: Option, pub(crate) directory: std::path::PathBuf, + pub(crate) locale: Locale, } impl SessionHeaderHistoryCell { pub(crate) fn new( model: impl Into, + reasoning_effort: Option, + permissions: Option, directory: std::path::PathBuf, version: impl Into, + locale: Locale, ) -> Self { Self { version: version.into(), model: model.into(), + reasoning_effort, + permissions, directory, + locale, } } + + fn format_directory(&self, max_width: Option) -> String { + let display = dirs::home_dir() + .and_then(|home| { + self.directory.strip_prefix(home).ok().map(|relative| { + if relative.as_os_str().is_empty() { + "~".to_string() + } else { + format!("~{}{}", std::path::MAIN_SEPARATOR, relative.display()) + } + }) + }) + .unwrap_or_else(|| self.directory.display().to_string()); + max_width.map_or(display.clone(), |width| { + center_truncate_path(&display, width) + }) + } } impl HistoryCell for SessionHeaderHistoryCell { - fn display_lines(&self, _width: u16) -> Vec> { - vec![ - Line::from(format!("Lime {}", self.version)), - Line::from(format!("model: {}", self.model)), - Line::from(format!("directory: {}", self.directory.display())), + fn display_lines(&self, width: u16) -> Vec> { + let Some(inner_width) = card_inner_width(width) else { + return Vec::new(); + }; + let model_label = self.locale.model_label(); + let directory_label = self.locale.cwd_label(); + let show_danger = self.permissions.as_deref() == Some(":danger-full-access"); + let label_width = [ + display_width(model_label), + display_width(directory_label), + if show_danger { + display_width(self.locale.permissions_label()) + } else { + 0 + }, ] + .into_iter() + .max() + .unwrap_or_default(); + + let model_prefix = padded_label(model_label, label_width); + let directory_prefix = padded_label(directory_label, label_width); + let mut model_spans = vec![ + Span::styled(format!("{model_prefix}: "), muted_style()), + Span::raw(self.model.clone()), + ]; + if let Some(effort) = self + .reasoning_effort + .as_deref() + .filter(|value| !value.is_empty()) + { + model_spans.push(Span::raw(format!(" {effort}"))); + } + model_spans.extend([ + Span::raw(" "), + Span::styled("/model", accent_style()), + Span::styled(self.locale.change_model_hint(), muted_style()), + ]); + + let directory_prefix = format!("{directory_prefix}: "); + let directory_width = inner_width.saturating_sub(display_width(&directory_prefix)); + let mut lines = vec![ + Line::from(vec![ + Span::styled(">_ ", muted_style()), + Span::styled("Lime", Style::default().bold()), + Span::styled(format!(" (v{})", self.version), muted_style()), + ]), + Line::default(), + Line::from(model_spans), + Line::from(vec![ + Span::styled(directory_prefix, muted_style()), + Span::raw(self.format_directory(Some(directory_width))), + ]), + ]; + if show_danger { + let permissions_label = padded_label(self.locale.permissions_label(), label_width); + lines.push(Line::from(vec![ + Span::styled(format!("{permissions_label}: "), muted_style()), + Span::styled(":danger-full-access", attention_style()), + ])); + } + let lines = lines + .into_iter() + .map(|line| truncate_line_with_ellipsis_if_overflow(line, inner_width)) + .collect(); + with_border(lines) } fn raw_lines(&self) -> Vec> { - self.display_lines(u16::MAX) + let mut model = self.model.clone(); + if let Some(effort) = self + .reasoning_effort + .as_deref() + .filter(|value| !value.is_empty()) + { + model.push(' '); + model.push_str(effort); + } + let mut lines = vec![ + Line::from(format!("Lime (v{})", self.version)), + Line::from(format!("{}: {model}", self.locale.model_label())), + Line::from(format!( + "{}: {}", + self.locale.cwd_label(), + self.format_directory(None) + )), + ]; + if self.permissions.as_deref() == Some(":danger-full-access") { + lines.push(Line::from(format!( + "{}: :danger-full-access", + self.locale.permissions_label() + ))); + } + lines + } +} + +fn card_inner_width(width: u16) -> Option { + (width >= 4).then(|| usize::from(width.saturating_sub(4)).min(SESSION_HEADER_MAX_INNER_WIDTH)) +} + +fn padded_label(label: &str, width: usize) -> String { + format!( + "{label}{}", + " ".repeat(width.saturating_sub(display_width(label))) + ) +} + +fn with_border(lines: Vec>) -> Vec> { + let content_width = lines.iter().map(line_width).max().unwrap_or_default(); + let border_width = content_width.saturating_add(2); + let mut output = Vec::with_capacity(lines.len().saturating_add(2)); + output.push(Line::styled( + format!("╭{}╮", "─".repeat(border_width)), + muted_style(), + )); + for line in lines { + let used = line_width(&line); + let mut spans = vec![Span::styled("│ ", muted_style())]; + spans.extend(line); + spans.push(Span::raw(" ".repeat(content_width.saturating_sub(used)))); + spans.push(Span::styled(" │", muted_style())); + output.push(Line::from(spans)); + } + output.push(Line::styled( + format!("╰{}╯", "─".repeat(border_width)), + muted_style(), + )); + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cell(locale: Locale, directory: &str) -> SessionHeaderHistoryCell { + SessionHeaderHistoryCell::new( + "fixture-model", + Some("high".to_string()), + Some(":workspace".to_string()), + directory.into(), + "1.2.3", + locale, + ) + } + + #[test] + fn session_header_is_a_bounded_codex_shaped_box() { + for width in [40, 80, 120] { + let lines = + cell(Locale::EnUs, "/workspace/a/very/long/project/path").display_lines(width); + assert!(lines + .first() + .is_some_and(|line| line.to_string().starts_with('╭'))); + assert!(lines + .iter() + .all(|line| line_width(line) <= usize::from(width))); + assert!(lines.iter().any(|line| line.to_string().contains("Lime"))); + assert!(lines + .last() + .is_some_and(|line| line.to_string().starts_with('╰'))); + } + } + + #[test] + fn session_header_labels_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + let rendered = cell(locale, "/workspace").display_lines(80); + let text = rendered.iter().map(Line::to_string).collect::(); + assert!(text.contains(locale.model_label()), "{locale:?}: {text}"); + assert!(text.contains(locale.cwd_label()), "{locale:?}: {text}"); + assert!(text.contains("/model"), "{locale:?}: {text}"); + assert!( + text.contains(locale.change_model_hint()), + "{locale:?}: {text}" + ); + } + } + + #[test] + fn dangerous_permissions_are_visible_without_becoming_a_fixed_header() { + let mut header = cell(Locale::EnUs, "/workspace"); + header.permissions = Some(":danger-full-access".to_string()); + let text = header + .display_lines(80) + .iter() + .map(Line::to_string) + .collect::(); + assert!(text.contains("permissions")); + assert!(text.contains(":danger-full-access")); } } diff --git a/lime-rs/crates/tui/src/lib.rs b/lime-rs/crates/tui/src/lib.rs index 24316dd29..c51390e60 100644 --- a/lime-rs/crates/tui/src/lib.rs +++ b/lime-rs/crates/tui/src/lib.rs @@ -4,6 +4,7 @@ mod bottom_pane; mod clipboard_copy; mod clipboard_paste; mod collaboration_modes; +#[allow(dead_code)] mod command_popup; #[allow(dead_code)] mod cwd_prompt; @@ -30,6 +31,7 @@ mod model_catalog; mod model_picker; mod multi_agents; mod pager_overlay; +#[allow(dead_code)] mod pending_input_preview; mod projection; #[allow(dead_code)] @@ -41,8 +43,11 @@ mod selection_list; mod session_resume; mod settings; mod slash_command; +// Kept as a compatibility delegate while callers converge on the widget owner. +#[allow(dead_code)] mod status_indicator; mod status_indicator_widget; +mod style; mod table_detect; mod terminal_hyperlinks; mod terminal_palette; diff --git a/lime-rs/crates/tui/src/locale.rs b/lime-rs/crates/tui/src/locale.rs index 793be6638..e6ed184b8 100644 --- a/lime-rs/crates/tui/src/locale.rs +++ b/lime-rs/crates/tui/src/locale.rs @@ -81,6 +81,26 @@ impl Locale { } } + pub(crate) fn change_model_hint(self) -> &'static str { + match self { + Self::ZhCn => " 切换模型", + Self::ZhTw => " 切換模型", + Self::EnUs => " to change", + Self::JaJp => " で変更", + Self::KoKr => "로 변경", + } + } + + pub(crate) fn composer_placeholder(self) -> &'static str { + match self { + Self::ZhCn => "询问 Lime 做任何事", + Self::ZhTw => "詢問 Lime 做任何事", + Self::EnUs => "Ask Lime to do anything", + Self::JaJp => "Lime に何でも依頼", + Self::KoKr => "Lime에게 무엇이든 요청", + } + } + pub(crate) fn effort_label(self) -> &'static str { match self { Self::ZhCn | Self::ZhTw => "推理", @@ -139,6 +159,50 @@ impl Locale { } } + pub(crate) fn queue_message_hint(self) -> &'static str { + match self { + Self::ZhCn => "Tab 排队消息", + Self::ZhTw => "Tab 排隊訊息", + Self::EnUs => "Tab to queue message", + Self::JaJp => "Tab でメッセージをキューに追加", + Self::KoKr => "Tab으로 메시지 대기열 추가", + } + } + + pub(crate) fn queue_short_hint(self) -> &'static str { + match self { + Self::ZhCn => "Tab 排队", + Self::ZhTw => "Tab 排隊", + Self::EnUs => "Tab to queue", + Self::JaJp => "Tab でキューに追加", + Self::KoKr => "Tab으로 대기열 추가", + } + } + + pub(crate) fn draft_ready_hint(self) -> &'static str { + match self { + Self::ZhCn => "草稿就绪", + Self::ZhTw => "草稿就緒", + Self::EnUs => "draft ready", + Self::JaJp => "下書き準備完了", + Self::KoKr => "초안 준비됨", + } + } + + /// 空闲 composer 的快捷键入口提示。 + /// + /// Codex 在没有草稿或活动回合时仍保留一条可操作的 footer,避免底部区域看起来像 + /// 未渲染。文案由 locale owner 提供,footer 只负责几何布局与截断。 + pub(crate) fn shortcuts_hint(self) -> &'static str { + match self { + Self::ZhCn => "? 查看快捷键", + Self::ZhTw => "? 查看快捷鍵", + Self::EnUs => "? for shortcuts", + Self::JaJp => "? ショートカット", + Self::KoKr => "? 단축키 보기", + } + } + pub(crate) fn history_search_label(self) -> &'static str { match self { Self::ZhCn => "反向搜索:", @@ -250,6 +314,11 @@ impl Locale { (Self::EnUs, SlashCommand::Resume) => "resume a previous session", (Self::JaJp, SlashCommand::Resume) => "以前のセッションを再開", (Self::KoKr, SlashCommand::Resume) => "이전 세션 재개", + (Self::ZhCn, SlashCommand::Mcp) => "查看 MCP 服务器和工具", + (Self::ZhTw, SlashCommand::Mcp) => "檢視 MCP 伺服器與工具", + (Self::EnUs, SlashCommand::Mcp) => "show MCP servers and tools", + (Self::JaJp, SlashCommand::Mcp) => "MCP サーバーとツールを表示", + (Self::KoKr, SlashCommand::Mcp) => "MCP 서버 및 도구 보기", (Self::ZhCn, SlashCommand::Vim) => "切换 Vim 编辑模式", (Self::ZhTw, SlashCommand::Vim) => "切換 Vim 編輯模式", (Self::EnUs, SlashCommand::Vim) => "toggle Vim composer mode", @@ -504,6 +573,217 @@ impl Locale { } } + pub(crate) fn mcp_inventory_title(self) -> &'static str { + match self { + Self::ZhCn => "MCP 工具", + Self::ZhTw => "MCP 工具", + Self::EnUs => "MCP Tools", + Self::JaJp => "MCP ツール", + Self::KoKr => "MCP 도구", + } + } + + pub(crate) fn mcp_no_servers(self) -> &'static str { + match self { + Self::ZhCn => "未配置 MCP 服务器。", + Self::ZhTw => "未設定 MCP 伺服器。", + Self::EnUs => "No MCP servers configured.", + Self::JaJp => "MCP サーバーが設定されていません。", + Self::KoKr => "구성된 MCP 서버가 없습니다.", + } + } + + pub(crate) fn mcp_tools_label(self) -> &'static str { + match self { + Self::ZhCn => "工具", + Self::ZhTw => "工具", + Self::EnUs => "Tools", + Self::JaJp => "ツール", + Self::KoKr => "도구", + } + } + + pub(crate) fn mcp_none(self) -> &'static str { + match self { + Self::ZhCn => "(无)", + Self::ZhTw => "(無)", + Self::EnUs => "(none)", + Self::JaJp => "(なし)", + Self::KoKr => "(없음)", + } + } + + pub(crate) fn mcp_no_tools_available(self) -> &'static str { + match self { + Self::ZhCn => "没有可用的 MCP 工具。", + Self::ZhTw => "沒有可用的 MCP 工具。", + Self::EnUs => "No MCP tools available.", + Self::JaJp => "利用可能な MCP ツールがありません。", + Self::KoKr => "사용 가능한 MCP 도구가 없습니다.", + } + } + + pub(crate) fn mcp_tool_unit(self, count: usize) -> &'static str { + match self { + Self::ZhCn => "工具", + Self::ZhTw => "工具", + Self::EnUs => { + if count == 1 { + "tool" + } else { + "tools" + } + } + Self::JaJp => "ツール", + Self::KoKr => "도구", + } + } + + pub(crate) fn mcp_resources_label(self) -> &'static str { + match self { + Self::ZhCn => "资源", + Self::ZhTw => "資源", + Self::EnUs => "Resources", + Self::JaJp => "リソース", + Self::KoKr => "리소스", + } + } + + pub(crate) fn mcp_resource_templates_label(self) -> &'static str { + match self { + Self::ZhCn => "资源模板", + Self::ZhTw => "資源範本", + Self::EnUs => "Resource templates", + Self::JaJp => "リソーステンプレート", + Self::KoKr => "리소스 템플릿", + } + } + + pub(crate) fn mcp_status(self, status: &str) -> &'static str { + match (self, status) { + (_, "connected") => match self { + Self::ZhCn => "已连接", + Self::ZhTw => "已連線", + Self::EnUs => "connected", + Self::JaJp => "接続済み", + Self::KoKr => "연결됨", + }, + (_, "starting") => match self { + Self::ZhCn => "启动中", + Self::ZhTw => "啟動中", + Self::EnUs => "starting", + Self::JaJp => "起動中", + Self::KoKr => "시작 중", + }, + (_, "authentication required") => match self { + Self::ZhCn => "需要认证", + Self::ZhTw => "需要驗證", + Self::EnUs => "authentication required", + Self::JaJp => "認証が必要", + Self::KoKr => "인증 필요", + }, + (_, "failed") => match self { + Self::ZhCn => "失败", + Self::ZhTw => "失敗", + Self::EnUs => "failed", + Self::JaJp => "失敗", + Self::KoKr => "실패", + }, + (_, "not started") => match self { + Self::ZhCn => "未启动", + Self::ZhTw => "未啟動", + Self::EnUs => "not started", + Self::JaJp => "未起動", + Self::KoKr => "시작되지 않음", + }, + (_, "disabled") => match self { + Self::ZhCn => "已禁用", + Self::ZhTw => "已停用", + Self::EnUs => "disabled", + Self::JaJp => "無効", + Self::KoKr => "비활성화됨", + }, + (_, "cancelled") => match self { + Self::ZhCn => "已取消", + Self::ZhTw => "已取消", + Self::EnUs => "cancelled", + Self::JaJp => "キャンセル済み", + Self::KoKr => "취소됨", + }, + (_, _) => match self { + Self::ZhCn => "未知", + Self::ZhTw => "未知", + Self::EnUs => "unknown", + Self::JaJp => "不明", + Self::KoKr => "알 수 없음", + }, + } + } + + pub(crate) fn mcp_auth_status(self, status: &str) -> &'static str { + match (self, status) { + (_, "unsupported") => match self { + Self::ZhCn => "不支持", + Self::ZhTw => "不支援", + Self::EnUs => "Unsupported", + Self::JaJp => "未対応", + Self::KoKr => "지원되지 않음", + }, + (_, "not logged in") => match self { + Self::ZhCn => "未登录", + Self::ZhTw => "未登入", + Self::EnUs => "Not logged in", + Self::JaJp => "未ログイン", + Self::KoKr => "로그인되지 않음", + }, + (_, "bearer token") => match self { + Self::ZhCn => "Bearer 令牌", + Self::ZhTw => "Bearer 權杖", + Self::EnUs => "Bearer token", + Self::JaJp => "Bearer トークン", + Self::KoKr => "Bearer 토큰", + }, + (_, "OAuth") => "OAuth", + (_, _) => match self { + Self::ZhCn => "未知", + Self::ZhTw => "未知", + Self::EnUs => "Unknown", + Self::JaJp => "不明", + Self::KoKr => "알 수 없음", + }, + } + } + + pub(crate) fn mcp_auth_label(self) -> &'static str { + match self { + Self::ZhCn => "认证", + Self::ZhTw => "驗證", + Self::EnUs => "Auth", + Self::JaJp => "認証", + Self::KoKr => "인증", + } + } + + pub(crate) fn mcp_use_verbose(self) -> &'static str { + match self { + Self::ZhCn => "使用 /mcp verbose 查看工具和资源。", + Self::ZhTw => "使用 /mcp verbose 查看工具與資源。", + Self::EnUs => "Use /mcp verbose for tools and resources.", + Self::JaJp => "/mcp verbose でツールとリソースを表示します。", + Self::KoKr => "도구와 리소스를 보려면 /mcp verbose를 사용하세요.", + } + } + + pub(crate) fn mcp_usage(self) -> &'static str { + match self { + Self::ZhCn => "用法:/mcp [verbose]", + Self::ZhTw => "用法:/mcp [verbose]", + Self::EnUs => "Usage: /mcp [verbose]", + Self::JaJp => "使い方:/mcp [verbose]", + Self::KoKr => "사용법: /mcp [verbose]", + } + } + pub(crate) fn transcript_pager_footer(self) -> &'static str { match self { Self::ZhCn => "上下滚动 PgUp/PgDn 翻页 Home/End 跳转 Ctrl+T/Esc/Q 关闭", @@ -1231,6 +1511,70 @@ impl Locale { } } + /// Stable primary/escape controls shown below an approval request. + /// + /// Keep this deliberately short: the approval pane can be rendered in a 40-column + /// terminal, where a long explanatory footer would otherwise hide the only safe way out. + pub(crate) fn approval_controls(self) -> &'static str { + match self { + Self::ZhCn => "Enter 确认 · Esc 取消", + Self::ZhTw => "Enter 確認 · Esc 取消", + Self::EnUs => "Enter confirm · Esc cancel", + Self::JaJp => "Enter 確定 · Esc キャンセル", + Self::KoKr => "Enter 확인 · Esc 취소", + } + } + + pub(crate) fn request_submit_hint(self) -> &'static str { + match self { + Self::ZhCn => "Enter 提交", + Self::ZhTw => "Enter 提交", + Self::EnUs => "Enter submit", + Self::JaJp => "Enter 送信", + Self::KoKr => "Enter 제출", + } + } + + pub(crate) fn request_cancel_hint(self) -> &'static str { + match self { + Self::ZhCn => "Esc 取消", + Self::ZhTw => "Esc 取消", + Self::EnUs => "Esc cancel", + Self::JaJp => "Esc キャンセル", + Self::KoKr => "Esc 취소", + } + } + + pub(crate) fn request_notes_hint(self) -> &'static str { + match self { + Self::ZhCn => "Tab 备注", + Self::ZhTw => "Tab 備註", + Self::EnUs => "Tab notes", + Self::JaJp => "Tab メモ", + Self::KoKr => "Tab 메모", + } + } + + pub(crate) fn request_select_hint(self) -> &'static str { + match self { + Self::ZhCn => "↑/↓ 选择", + Self::ZhTw => "↑/↓ 選擇", + Self::EnUs => "↑/↓ select", + Self::JaJp => "↑/↓ 選択", + Self::KoKr => "↑/↓ 선택", + } + } + + pub(crate) fn request_question_nav_hint(self) -> &'static str { + match self { + Self::ZhCn => "←/→ 切换问题", + Self::ZhTw => "←/→ 切換問題", + Self::EnUs => "←/→ questions", + Self::JaJp => "←/→ 質問", + Self::KoKr => "←/→ 질문", + } + } + pub(crate) fn mcp_elicitation_title(self, server_name: &str) -> String { match self { Self::ZhCn => format!("MCP 请求:{server_name}"), @@ -1488,6 +1832,7 @@ impl Locale { } } + #[allow(dead_code)] pub(crate) fn resume_title(self) -> &'static str { match self { Self::ZhCn => "选择会话", @@ -1508,6 +1853,7 @@ impl Locale { } } + #[allow(dead_code)] pub(crate) fn resume_label(self) -> &'static str { match self { Self::ZhCn => "恢复", @@ -1784,6 +2130,16 @@ impl Locale { Self::KoKr => "Tab으로 메모 추가", } } + + pub(crate) fn auto_resolution_countdown(self, remaining: &str) -> String { + match self { + Self::ZhCn => format!("将在 {remaining} 后自动继续"), + Self::ZhTw => format!("將在 {remaining} 後自動繼續"), + Self::EnUs => format!("auto-resolves in {remaining}"), + Self::JaJp => format!("{remaining}後に自動処理します"), + Self::KoKr => format!("{remaining} 후 자동으로 계속합니다"), + } + } } #[cfg(test)] @@ -2224,6 +2580,24 @@ mod tests { } } + #[test] + fn interactive_overlay_controls_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(!locale.approval_controls().is_empty()); + assert!(!locale.request_submit_hint().is_empty()); + assert!(!locale.request_cancel_hint().is_empty()); + assert!(!locale.request_notes_hint().is_empty()); + assert!(!locale.request_select_hint().is_empty()); + assert!(!locale.request_question_nav_hint().is_empty()); + } + } + #[test] fn working_directory_messages_cover_all_product_locales() { let cwd = "/tmp/project"; @@ -2314,6 +2688,22 @@ mod tests { assert!(Locale::ZhCn.transcript_pager_footer().contains("Ctrl+T")); } + #[test] + fn mcp_inventory_labels_cover_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(!locale.mcp_inventory_title().is_empty()); + assert!(!locale.mcp_no_servers().is_empty()); + assert!(!locale.mcp_usage().is_empty()); + } + assert_eq!(Locale::EnUs.mcp_usage(), "Usage: /mcp [verbose]"); + } + #[test] fn resume_picker_controls_cover_all_product_locales() { for locale in [ @@ -2348,4 +2738,23 @@ mod tests { assert!(!locale.resume_transcript_empty().is_empty()); } } + + #[test] + fn auto_resolution_countdown_covers_all_product_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + assert!(locale + .auto_resolution_countdown("1m 00s") + .contains("1m 00s")); + } + assert_eq!( + Locale::EnUs.auto_resolution_countdown("59s"), + "auto-resolves in 59s" + ); + } } diff --git a/lime-rs/crates/tui/src/markdown/table.rs b/lime-rs/crates/tui/src/markdown/table.rs index 427a7fcbd..ac50efae3 100644 --- a/lime-rs/crates/tui/src/markdown/table.rs +++ b/lime-rs/crates/tui/src/markdown/table.rs @@ -1,10 +1,11 @@ //! Width-aware Markdown table rendering adapted from Codex TUI. use pulldown_cmark::Alignment; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use super::{TableRow, TableState}; +use crate::style::table_separator_style; use crate::terminal_hyperlinks::{wrap_hyperlink_line, HyperlinkLine}; use crate::width::display_width; @@ -328,7 +329,7 @@ fn render_separator(widths: &[usize], separator: char) -> HyperlinkLine { .map(|width| segment.repeat(width + CELL_PADDING * 2)) .collect::>() .join(&" ".repeat(COLUMN_GAP)); - HyperlinkLine::new(Line::styled(text, Style::default().fg(Color::DarkGray))) + HyperlinkLine::new(Line::styled(text, table_separator_style())) } fn render_records( @@ -362,7 +363,7 @@ fn render_records( width.unwrap_or_else(|| out.iter().map(HyperlinkLine::width).max().unwrap_or(1)); out.push(HyperlinkLine::new(Line::styled( "─".repeat(separator_width.max(1)), - Style::default().fg(Color::DarkGray), + table_separator_style(), ))); } } diff --git a/lime-rs/crates/tui/src/model_picker.rs b/lime-rs/crates/tui/src/model_picker.rs index b2569e65b..e1dfa83f3 100644 --- a/lime-rs/crates/tui/src/model_picker.rs +++ b/lime-rs/crates/tui/src/model_picker.rs @@ -1,13 +1,18 @@ use app_server_protocol::protocol::v2::Model; use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph}; use ratatui::Frame; +use crate::bottom_pane::selection_row_layout::{ + centered_popup, visible_item_window, wrap_row, SelectionDescriptionLayout, SelectionRow, + MAX_POPUP_ROWS, +}; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::locale::Locale; +use crate::style::{accent_style, muted_style}; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ModelSelection { @@ -74,14 +79,26 @@ impl ModelPicker { } match key.code { KeyCode::Esc => ModelPickerAction::Cancel, - KeyCode::Up | KeyCode::Char('k') => { - self.selected = self.selected.saturating_sub(1); - ModelPickerAction::None - } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Up | KeyCode::Char('p') | KeyCode::Char('k') + if key.code == KeyCode::Up + || key.modifiers.contains(KeyModifiers::CONTROL) => + { let count = self.visible_indices().len(); if count > 0 { - self.selected = (self.selected + 1).min(count - 1); + self.selected = self + .selected + .checked_sub(1) + .unwrap_or(count.saturating_sub(1)); + } + ModelPickerAction::None + } + KeyCode::Down | KeyCode::Char('n') | KeyCode::Char('j') + if key.code == KeyCode::Down + || key.modifiers.contains(KeyModifiers::CONTROL) => + { + let count = self.visible_indices().len(); + if count > 0 { + self.selected = (self.selected + 1) % count; } ModelPickerAction::None } @@ -94,7 +111,11 @@ impl ModelPicker { self.selected = 0; ModelPickerAction::None } - KeyCode::Char(ch) => { + KeyCode::Char(ch) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { self.query.push(ch); self.selected = 0; ModelPickerAction::None @@ -139,12 +160,12 @@ pub(crate) fn render_with_locale( locale: Locale, ) { let width = area.width.saturating_mul(4).saturating_div(5).clamp(28, 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)); + let height = area + .height + .saturating_mul(3) + .saturating_div(4) + .clamp(7, MAX_POPUP_ROWS as u16 + 4); + let popup = centered_popup(area, width, height); frame.render_widget(Clear, popup); let chunks = Layout::default() @@ -166,7 +187,7 @@ pub(crate) fn render_with_locale( } else { picker.query().to_string() }, - Style::default().fg(Color::DarkGray), + muted_style(), ), ]); frame.render_widget( @@ -178,44 +199,51 @@ pub(crate) fn render_with_locale( chunks[0], ); - let items = picker - .visible_models() + let row_width = chunks[1].width.saturating_sub(2); + let desc_col = usize::from(row_width.saturating_mul(2).saturating_div(5).max(1)); + let visible_models = picker.visible_models(); + let max_visible = MAX_POPUP_ROWS.min(usize::from(chunks[1].height).max(1)); + let (start, end) = visible_item_window(picker.selected, visible_models.len(), max_visible); + let items = visible_models .into_iter() + .skip(start) + .take(end.saturating_sub(start)) .map(|model| { - let label = format!("{} [{}]", model.display_name, model.provider_id); - ListItem::new(truncate_line_with_ellipsis_if_overflow( - Line::from(label), - usize::from(chunks[1].width.saturating_sub(2)), + let row = SelectionRow::new( + model.display_name.clone(), + Some(format!("[{}]", model.provider_id)), + Vec::new(), + ); + ListItem::new(wrap_row( + &row, + desc_col, + row_width, + SelectionDescriptionLayout::StackBelowWhenNarrow { + min_description_width: 10, + }, )) }) .collect::>(); let mut state = ListState::default(); if !items.is_empty() { - state.select(Some(picker.selected)); + state.select(Some(picker.selected.saturating_sub(start))); } 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("> "), + .highlight_style(accent_style()) + .highlight_symbol("› "), chunks[1], &mut state, ); - let footer = if picker.visible_models().is_empty() { + let footer = if end == start { locale.picker_empty() } else { locale.picker_footer() }; frame.render_widget( - Paragraph::new(Line::from(Span::styled( - footer, - Style::default().fg(Color::DarkGray), - ))) - .block(Block::default().borders(Borders::BOTTOM)), + Paragraph::new(Line::from(Span::styled(footer, muted_style()))) + .block(Block::default().borders(Borders::BOTTOM)), chunks[2], ); } @@ -278,6 +306,69 @@ mod tests { ); } + #[test] + fn searchable_picker_keeps_plain_vim_letters_for_query_input() { + let mut picker = ModelPicker::new(vec![ + model("alpha", "fixture", false, true), + model("jupiter", "fixture", false, false), + model("kilo", "fixture", false, false), + ]); + + assert_eq!( + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))), + ModelPickerAction::None + ); + assert_eq!(picker.query(), "j"); + assert_eq!(picker.visible_models()[0].model, "jupiter"); + + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Backspace, + KeyModifiers::NONE, + ))); + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::NONE, + ))); + assert_eq!(picker.query(), "k"); + assert_eq!(picker.visible_models()[0].model, "kilo"); + } + + #[test] + fn picker_navigation_wraps_and_accepts_control_bindings() { + let mut picker = ModelPicker::new(vec![ + model("first", "fixture", false, true), + model("second", "fixture", false, false), + ]); + + picker.handle_event(Event::Key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE))); + assert_eq!(picker.selected, 1); + + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + ))); + assert_eq!(picker.selected, 0); + + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('k'), + KeyModifiers::CONTROL, + ))); + assert_eq!(picker.selected, 1); + } + + #[test] + fn unhandled_control_keys_do_not_pollute_model_query() { + let mut picker = ModelPicker::new(vec![model("first", "fixture", false, true)]); + picker.handle_event(Event::Key(KeyEvent::new( + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ))); + assert!(picker.query().is_empty()); + } + #[test] fn picker_escape_cancels_and_render_stays_bounded() { let mut picker = ModelPicker::new(vec![model("模型", "提供方", false, true)]); @@ -290,4 +381,34 @@ mod tests { .draw(|frame| render(frame, frame.area(), &picker)) .expect("draw"); } + + #[test] + fn long_model_catalog_keeps_selected_row_inside_bounded_popup() { + let models = (0..20) + .map(|index| model(&format!("model-{index:02}"), "fixture", false, index == 0)) + .collect(); + let mut picker = ModelPicker::new(models); + for _ in 0..12 { + picker.handle_event(Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE))); + } + + let mut terminal = Terminal::new(TestBackend::new(80, 20)).expect("terminal"); + terminal + .draw(|frame| render(frame, frame.area(), &picker)) + .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"); + assert!( + text.contains("model-12"), + "selected model was clipped: {text}" + ); + assert!(text.lines().all(|line| line.chars().count() <= 80)); + } } diff --git a/lime-rs/crates/tui/src/pager_overlay.rs b/lime-rs/crates/tui/src/pager_overlay.rs index 072bd016a..d48f37d19 100644 --- a/lime-rs/crates/tui/src/pager_overlay.rs +++ b/lime-rs/crates/tui/src/pager_overlay.rs @@ -14,6 +14,7 @@ use crate::terminal_hyperlinks::{HyperlinkLine, HyperlinkParagraph}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum PagerAction { Consumed, + LoadOlderHistory, Close, } @@ -35,6 +36,7 @@ pub(crate) struct PagerOverlay { page_height: Cell, max_scroll: Cell, pinned_to_bottom: Cell, + older_history_available: Cell, /// Previous transcript input used to preserve the visible logical anchor when the /// projection prepends history or the terminal width changes. Static pagers do not /// populate this cache. @@ -82,6 +84,7 @@ impl PagerOverlay { page_height: Cell::new(1), max_scroll: Cell::new(0), pinned_to_bottom: Cell::new(false), + older_history_available: Cell::new(false), previous_transcript_lines: RefCell::new(None), previous_content_width: Cell::new(0), } @@ -95,6 +98,7 @@ impl PagerOverlay { page_height: Cell::new(1), max_scroll: Cell::new(0), pinned_to_bottom: Cell::new(true), + older_history_available: Cell::new(false), previous_transcript_lines: RefCell::new(None), previous_content_width: Cell::new(0), } @@ -104,6 +108,21 @@ impl PagerOverlay { self.static_lines.is_none() } + pub(crate) fn set_older_history_available(&self, available: bool) { + self.older_history_available.set(available); + } + + /// Keep the transcript at the actual beginning after Home loads all older pages. + pub(crate) fn reset_transcript_anchor_at_top(&self) { + if !self.is_transcript() { + return; + } + self.pinned_to_bottom.set(false); + self.scroll.set(0); + *self.previous_transcript_lines.borrow_mut() = None; + self.previous_content_width.set(0); + } + pub(crate) fn handle_event(&mut self, event: &Event) -> PagerAction { let Event::Key(key) = event else { return PagerAction::Consumed; @@ -111,6 +130,9 @@ impl PagerOverlay { if key.kind != KeyEventKind::Press { return PagerAction::Consumed; } + let load_older = self.is_transcript() + && self.older_history_available.get() + && matches!(key.code, KeyCode::Up | KeyCode::PageUp | KeyCode::Home); match key.code { KeyCode::Esc | KeyCode::Char('q') => return PagerAction::Close, KeyCode::Char(value) @@ -156,7 +178,11 @@ impl PagerOverlay { self.pinned_to_bottom .set(self.scroll.get() == self.max_scroll.get()); } - PagerAction::Consumed + if load_older && self.scroll.get() == 0 { + PagerAction::LoadOlderHistory + } else { + PagerAction::Consumed + } } pub(crate) fn render( @@ -303,7 +329,26 @@ impl PagerOverlay { .map(|(index, start)| (index, old_scroll.saturating_sub(*start))) .unwrap_or((0, old_scroll)); - let mapped_line = if lines.len() >= previous.len() + let common_prefix = previous + .iter() + .zip(lines) + .take_while(|(old, new)| old == new) + .count(); + let common_suffix = previous + .iter() + .rev() + .zip(lines.iter().rev()) + .take_while(|(old, new)| old == new) + .count() + .min(previous.len().saturating_sub(common_prefix)); + let mapped_line = if lines.len() == previous.len() + && (common_prefix > 0 || common_suffix > 0 || previous.len() == 1) + { + // Streaming updates replace the active canonical line in place. The visible text (and + // therefore `HyperlinkLine` equality) changes, but its logical transcript identity is + // stable, so keep the same line index while recomputing its wrapped height below. + old_line.0 + } else if lines.len() >= previous.len() && lines[lines.len() - previous.len()..] == previous[..] { old_line.0 + lines.len() - previous.len() @@ -547,6 +592,69 @@ mod tests { ); } + #[test] + fn transcript_overlay_requests_older_history_when_scrolled_to_the_top() { + let mut overlay = PagerOverlay::transcript(Locale::EnUs); + overlay.set_older_history_available(true); + let lines = (0..12) + .map(|index| HyperlinkLine::from(format!("line {index}"))) + .collect::>(); + let mut terminal = Terminal::new(TestBackend::new(24, 6)).expect("terminal"); + terminal + .draw(|frame| overlay.render(frame, frame.area(), Locale::EnUs, &lines)) + .expect("initial draw"); + + assert_eq!( + overlay.handle_event(&key(KeyCode::Home)), + PagerAction::LoadOlderHistory + ); + assert_eq!(overlay.scroll.get(), 0); + assert!(!overlay.pinned_to_bottom.get()); + assert_eq!( + overlay.handle_event(&key(KeyCode::PageUp)), + PagerAction::LoadOlderHistory + ); + } + + #[test] + fn static_overlay_never_requests_older_history() { + let mut overlay = PagerOverlay::new("STATUS".to_string(), vec![Line::raw("line")]); + assert_eq!( + overlay.handle_event(&key(KeyCode::Home)), + PagerAction::Consumed + ); + } + + #[test] + fn transcript_overlay_without_older_history_consumes_top_navigation() { + let mut overlay = PagerOverlay::transcript(Locale::EnUs); + assert_eq!( + overlay.handle_event(&key(KeyCode::Home)), + PagerAction::Consumed + ); + } + + #[test] + fn transcript_overlay_reset_anchor_keeps_newly_loaded_beginning_visible() { + let overlay = PagerOverlay::transcript(Locale::EnUs); + let old_lines = vec![HyperlinkLine::from("oldest loaded")]; + let mut terminal = Terminal::new(TestBackend::new(24, 6)).expect("terminal"); + terminal + .draw(|frame| overlay.render(frame, frame.area(), Locale::EnUs, &old_lines)) + .expect("initial draw"); + + overlay.reset_transcript_anchor_at_top(); + let lines = vec![ + HyperlinkLine::from("actual oldest"), + HyperlinkLine::from("oldest loaded"), + ]; + terminal + .draw(|frame| overlay.render(frame, frame.area(), Locale::EnUs, &lines)) + .expect("expanded draw"); + + assert_eq!(overlay.scroll.get(), 0); + } + #[test] fn transcript_overlay_preserves_manual_scroll_when_projection_grows() { let mut overlay = PagerOverlay::transcript(Locale::EnUs); @@ -621,4 +729,36 @@ mod tests { assert_eq!(overlay.scroll.get(), expected); assert!(!overlay.pinned_to_bottom.get()); } + + #[test] + fn transcript_overlay_remaps_manual_anchor_when_streaming_line_grows_in_place() { + let overlay = PagerOverlay::transcript(Locale::EnUs); + let initial = vec![ + HyperlinkLine::from("stable header"), + HyperlinkLine::from("anchor line"), + HyperlinkLine::from("tail line"), + ]; + let mut terminal = Terminal::new(TestBackend::new(18, 4)).expect("terminal"); + terminal + .draw(|frame| overlay.render(frame, frame.area(), Locale::EnUs, &initial)) + .expect("initial draw"); + overlay.pinned_to_bottom.set(false); + overlay.scroll.set(wrapped_line_starts(&initial, 18)[1]); + + let updated = vec![ + HyperlinkLine::from("stable header that grew while streaming"), + HyperlinkLine::from("anchor line"), + HyperlinkLine::from("tail line"), + ]; + terminal + .draw(|frame| overlay.render(frame, frame.area(), Locale::EnUs, &updated)) + .expect("streaming redraw"); + + assert_eq!( + overlay.scroll.get(), + wrapped_line_starts(&updated, 18)[1], + "the same logical anchor should remain visible after an in-place stream update" + ); + assert!(!overlay.pinned_to_bottom.get()); + } } diff --git a/lime-rs/crates/tui/src/pending_input_preview.rs b/lime-rs/crates/tui/src/pending_input_preview.rs index 4063b65b7..10b7b59cb 100644 --- a/lime-rs/crates/tui/src/pending_input_preview.rs +++ b/lime-rs/crates/tui/src/pending_input_preview.rs @@ -1,317 +1,9 @@ -use app_server_protocol::protocol::v2::{QueuedSubmission, UserInput}; -use ratatui::layout::Rect; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::Paragraph; -use ratatui::Frame; +//! Compatibility re-export for the moved pending-input preview owner. +//! +//! New consumers must import `bottom_pane::pending_input_preview`; this module +//! remains only as a migration boundary for historical integrations. -use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; -use crate::locale::Locale; -use crate::terminal_hyperlinks::{wrap_hyperlink_line, HyperlinkLine}; - -const MAX_VISIBLE_SUBMISSIONS: usize = 2; -const MAX_LINES_PER_SUBMISSION: usize = 2; - -pub(crate) fn desired_height(submissions: &[QueuedSubmission], width: u16, locale: Locale) -> u16 { - u16::try_from(preview_lines(submissions, width, locale).len()).unwrap_or(u16::MAX) -} - -pub(crate) fn render( - frame: &mut Frame<'_>, - area: Rect, - submissions: &[QueuedSubmission], - locale: Locale, -) { - if area.is_empty() { - return; - } - frame.render_widget( - Paragraph::new(preview_lines(submissions, area.width, locale)), - area, - ); -} - -fn preview_lines( - submissions: &[QueuedSubmission], - width: u16, - locale: Locale, -) -> Vec> { - if submissions.is_empty() || width < 4 { - return Vec::new(); - } - - let mut lines = vec![truncate_line_with_ellipsis_if_overflow( - Line::styled( - format!("• {} ({})", locale.status("queued"), submissions.len()), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ), - usize::from(width), - )]; - for submission in submissions.iter().take(MAX_VISIBLE_SUBMISSIONS) { - lines.extend(submission_preview_lines(submission, width, locale)); - } - if submissions.len() > MAX_VISIBLE_SUBMISSIONS { - lines.push(truncate_line_with_ellipsis_if_overflow( - Line::styled( - format!(" … +{}", submissions.len() - MAX_VISIBLE_SUBMISSIONS), - Style::default().fg(Color::DarkGray), - ), - usize::from(width), - )); - } - if submissions.last().is_some_and(can_restore_submission) { - lines.push(truncate_line_with_ellipsis_if_overflow( - Line::styled( - format!(" {}", locale.edit_queued_input_hint()), - Style::default().fg(Color::DarkGray), - ), - usize::from(width), - )); - } - lines -} - -pub(crate) fn can_restore_submission(submission: &QueuedSubmission) -> bool { - let mut text_count = 0usize; - !submission.input.is_empty() - && submission.input.iter().all(|input| match input { - UserInput::Text { text_elements, .. } => { - text_count += 1; - text_count <= 1 && text_elements.is_empty() - } - UserInput::LocalImage { detail, .. } | UserInput::Image { detail, .. } => { - detail.is_none() - } - UserInput::Skill { .. } => true, - UserInput::Mention { .. } => false, - }) -} - -fn submission_preview_lines( - submission: &QueuedSubmission, - width: u16, - locale: Locale, -) -> Vec> { - let content_width = usize::from(width.saturating_sub(4).max(1)); - let summary = submission_summary(submission, locale); - let mut wrapped = summary - .lines() - .flat_map(|line| { - wrap_hyperlink_line( - &HyperlinkLine::new(Line::styled( - line.to_string(), - Style::default().add_modifier(Modifier::ITALIC), - )), - content_width, - ) - }) - .map(|line| line.line) - .collect::>(); - if wrapped.is_empty() { - wrapped.push(Line::styled( - locale.not_set_label(), - Style::default().add_modifier(Modifier::ITALIC), - )); - } - - let overflow = wrapped.len() > MAX_LINES_PER_SUBMISSION; - wrapped.truncate(MAX_LINES_PER_SUBMISSION); - let mut lines = wrapped - .into_iter() - .enumerate() - .map(|(index, line)| { - let mut spans = vec![Span::styled( - if index == 0 { " ↳ " } else { " " }, - Style::default().fg(Color::DarkGray), - )]; - spans.extend(line.spans); - Line::from(spans) - }) - .collect::>(); - if overflow { - lines.push(Line::styled(" …", Style::default().fg(Color::DarkGray))); - } - lines -} - -fn submission_summary(submission: &QueuedSubmission, locale: Locale) -> String { - let mut parts = Vec::new(); - let mut image_count = 0usize; - for input in &submission.input { - match input { - UserInput::Text { text, .. } if !text.trim().is_empty() => { - parts.push(text.trim().to_string()); - } - UserInput::Image { .. } | UserInput::LocalImage { .. } => image_count += 1, - UserInput::Skill { name, .. } => parts.push(format!("${name}")), - UserInput::Mention { name, .. } => parts.push(format!("@{name}")), - UserInput::Text { .. } => {} - } - } - if image_count > 0 { - parts.push(format!("[{} ×{image_count}]", locale.image_label())); - } - parts.join("\n") -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::line_truncation::line_width; - - fn submission(id: &str, input: Vec) -> QueuedSubmission { - QueuedSubmission { - id: id.to_string(), - input, - client_user_message_id: format!("client-{id}"), - } - } - - fn line_text(line: &Line<'_>) -> String { - line.spans - .iter() - .map(|span| span.content.as_ref()) - .collect() - } - - #[test] - fn preview_uses_canonical_multimodal_queue_and_caps_visible_items() { - let submissions = vec![ - submission( - "queue-1", - vec![ - UserInput::LocalImage { - detail: None, - path: "/tmp/one.png".to_string(), - }, - UserInput::Text { - text: "first follow-up".to_string(), - text_elements: Vec::new(), - }, - ], - ), - submission( - "queue-2", - vec![UserInput::Text { - text: "second follow-up".to_string(), - text_elements: Vec::new(), - }], - ), - submission( - "queue-3", - vec![UserInput::Text { - text: "third follow-up".to_string(), - text_elements: Vec::new(), - }], - ), - ]; - - let text = preview_lines(&submissions, 80, Locale::EnUs) - .iter() - .map(line_text) - .collect::>() - .join("\n"); - - assert!(text.contains("queued (3)"), "{text}"); - assert!(text.contains("first follow-up"), "{text}"); - assert!(text.contains("[image ×1]"), "{text}"); - assert!(text.contains("second follow-up"), "{text}"); - assert!(!text.contains("third follow-up"), "{text}"); - assert!(text.contains("… +1"), "{text}"); - } - - #[test] - fn narrow_preview_wraps_and_caps_each_submission() { - let submissions = vec![submission( - "queue-1", - vec![UserInput::Text { - text: "one two three four five six seven eight nine".to_string(), - text_elements: Vec::new(), - }], - )]; - - let lines = preview_lines(&submissions, 12, Locale::EnUs); - - assert_eq!(lines.len(), 5); - assert_eq!(line_text(&lines[3]), " …"); - assert!(line_text(lines.last().expect("edit hint")).contains("Alt+Up")); - assert!(lines.iter().all(|line| line_width(line) <= 12)); - } - - #[test] - fn edit_hint_requires_a_lossless_composer_projection() { - let local = submission( - "local", - vec![UserInput::LocalImage { - detail: None, - path: "/tmp/local.png".to_string(), - }], - ); - let remote = submission( - "remote", - vec![UserInput::Image { - detail: None, - url: "https://example.test/image.png".to_string(), - }], - ); - let skill = submission( - "skill", - vec![UserInput::Skill { - name: "review".to_string(), - path: "/skills/review/SKILL.md".to_string(), - }], - ); - let structured = submission( - "structured", - vec![ - UserInput::Text { - text: "first segment".to_string(), - text_elements: Vec::new(), - }, - UserInput::Text { - text: "second segment".to_string(), - text_elements: Vec::new(), - }, - ], - ); - - assert!(can_restore_submission(&local)); - assert!(can_restore_submission(&remote)); - assert!(can_restore_submission(&skill)); - assert!(!can_restore_submission(&structured)); - assert!(preview_lines(&[remote], 80, Locale::EnUs) - .iter() - .map(line_text) - .collect::() - .contains("Alt+Up")); - } - - #[test] - fn queue_header_and_image_label_cover_all_product_locales() { - let cases = [ - (Locale::ZhCn, "已排队", "图片"), - (Locale::ZhTw, "已排隊", "圖片"), - (Locale::EnUs, "queued", "image"), - (Locale::JaJp, "キューに追加済み", "画像"), - (Locale::KoKr, "대기열에 추가됨", "이미지"), - ]; - for (locale, queued, image) in cases { - let lines = preview_lines( - &[submission( - "queue-1", - vec![UserInput::Image { - detail: None, - url: "https://example.test/image.png".to_string(), - }], - )], - 80, - locale, - ); - let text = lines.iter().map(line_text).collect::>().join("\n"); - assert!(text.contains(queued), "{locale:?}: {text}"); - assert!(text.contains(image), "{locale:?}: {text}"); - } - } -} +#[allow(unused_imports)] +pub(crate) use crate::bottom_pane::pending_input_preview::{ + can_restore_submission, desired_height, render, +}; diff --git a/lime-rs/crates/tui/src/projection.rs b/lime-rs/crates/tui/src/projection.rs index 8cc438ba8..b7c5e6c19 100644 --- a/lime-rs/crates/tui/src/projection.rs +++ b/lime-rs/crates/tui/src/projection.rs @@ -16,6 +16,23 @@ use crate::history_filter::{ }; use crate::multi_agents; +fn latest_summary_line(text: &str) -> Option { + text.lines().rev().find_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with("\nPreparing response".to_string(), + summary_index: 0, + }, + )); + assert_eq!(projection.status(), "Preparing response"); + + projection.apply(ServerNotification::TurnCompleted( + TurnCompletedNotification { + thread_id: "thread-1".to_string(), + turn: test_turn("turn-1", TurnStatus::Completed, Vec::new()), + }, + )); + assert_eq!(projection.status(), "ready"); +} + +#[test] +fn explicit_status_clears_reasoning_summary_header() { + let mut projection = ConversationProjection::default(); + projection.start_turn("turn-1".to_string()); + projection.apply(ServerNotification::ReasoningSummaryTextDelta( + ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: "**Checking**".to_string(), + summary_index: 0, + }, + )); + assert_eq!(projection.status(), "Checking"); + + projection.set_status("waiting for input"); + assert_eq!(projection.status(), "waiting for input"); +} + +#[test] +fn late_reasoning_section_boundary_does_not_mutate_completed_history() { + let mut projection = ConversationProjection::default(); + projection.apply(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + metadata: None, + summary: vec!["已完成".to_string()], + content: Vec::new(), + }, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1, + }, + )); + projection.apply(ServerNotification::ReasoningSummaryPartAdded( + ReasoningSummaryPartAddedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + summary_index: 1, + }, + )); + projection.apply(ServerNotification::ReasoningSummaryTextDelta( + ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: " late".to_string(), + summary_index: 1, + }, + )); + + assert_eq!(projection.entries()[0].text, "已完成"); + assert!(!projection.entries()[0].streaming); +} + #[test] fn hydrated_thread_uses_the_same_phase_aware_final_answer_rule() { let mut projection = ConversationProjection::default(); @@ -1564,6 +1717,166 @@ fn turn_completion_replaces_streaming_item_with_canonical_text() { assert!(!projection.entries()[0].streaming); } +#[test] +fn terminal_turn_closes_unrepaired_stream_tail_against_late_delta() { + let mut projection = ConversationProjection::default(); + projection.apply(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "answer-1".to_string(), + delta: "部分".to_string(), + }, + )); + + projection.apply(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::Interrupted, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1), + }, + }, + )); + projection.apply(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "answer-1".to_string(), + delta: " late".to_string(), + }, + )); + + assert_eq!(projection.entries().len(), 1); + assert_eq!(projection.entries()[0].text, "部分"); + assert!(!projection.entries()[0].streaming); +} + +#[test] +fn terminal_turn_rejects_late_delta_for_unknown_item() { + let mut projection = ConversationProjection::default(); + projection.apply(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::Failed, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1), + }, + }, + )); + + projection.apply(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "missing-answer".to_string(), + delta: "late output".to_string(), + }, + )); + + assert!(projection.entries().is_empty()); +} + +#[test] +fn terminal_turn_rejects_late_item_started_but_accepts_canonical_completion() { + let mut projection = ConversationProjection::default(); + projection.apply(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::Interrupted, + error: None, + started_at: Some(1), + completed_at: Some(2), + duration_ms: Some(1), + }, + }, + )); + + projection.apply(ServerNotification::ItemStarted(ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: agent_message("late-item", "provisional", None), + started_at_ms: 3, + })); + assert!(projection.entries().is_empty()); + + projection.apply(ServerNotification::ItemCompleted( + ItemCompletedNotification { + item: agent_message("late-item", "canonical repair", None), + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 4, + }, + )); + assert_eq!(projection.entries().len(), 1); + assert_eq!(projection.entries()[0].text, "canonical repair"); + assert!(!projection.entries()[0].streaming); +} + +#[test] +fn a_new_turn_can_create_a_streaming_item_after_a_terminal_turn() { + let mut projection = ConversationProjection::default(); + projection.apply(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), + }, + }, + )); + projection.apply(ServerNotification::TurnStarted( + app_server_protocol::protocol::v2::TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: Turn { + id: "turn-2".to_string(), + items: Vec::new(), + items_view: TurnItemsView::Full, + status: TurnStatus::InProgress, + error: None, + started_at: Some(3), + completed_at: None, + duration_ms: None, + }, + }, + )); + + projection.apply(ServerNotification::AgentMessageDelta( + AgentMessageDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-2".to_string(), + item_id: "answer-2".to_string(), + delta: "new output".to_string(), + }, + )); + + assert_eq!(projection.entries().len(), 1); + assert_eq!(projection.entries()[0].text, "new output"); + assert!(projection.entries()[0].streaming); +} + #[test] fn turn_completion_inserts_missing_canonical_items_before_known_following_items() { let mut projection = ConversationProjection::default(); diff --git a/lime-rs/crates/tui/src/resume_picker.rs b/lime-rs/crates/tui/src/resume_picker.rs index 170e70dff..fd80fec73 100644 --- a/lime-rs/crates/tui/src/resume_picker.rs +++ b/lime-rs/crates/tui/src/resume_picker.rs @@ -10,7 +10,7 @@ use futures::StreamExt; 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::widgets::{Clear, Paragraph}; use ratatui::Frame; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -39,6 +39,7 @@ use page_loading::{PageCursor, PageLoadMode, PaginationState}; mod transcript_preview; const MAX_THREADS: u32 = 100; +const PICKER_LIST_HORIZONTAL_INSET: u16 = 4; #[derive(Debug, Clone, PartialEq, Eq)] #[allow(dead_code)] @@ -584,21 +585,30 @@ impl PickerState { return PickerAction::ToggleExpanded; } match key.code { - KeyCode::Up | KeyCode::Char('k') => { + KeyCode::Up | KeyCode::Char('p') | KeyCode::Char('k') + if key.code == KeyCode::Up || key.modifiers.contains(KeyModifiers::CONTROL) => + { self.selected = self.selected.saturating_sub(1); PickerAction::MoveUp } - KeyCode::PageUp => { + KeyCode::PageUp | KeyCode::Char('b') + if key.code == KeyCode::PageUp || key.modifiers.contains(KeyModifiers::CONTROL) => + { self.selected = self.selected.saturating_sub(10); PickerAction::MoveUp } - KeyCode::Down | KeyCode::Char('j') => { + KeyCode::Down | KeyCode::Char('n') | KeyCode::Char('j') + if key.code == KeyCode::Down || key.modifiers.contains(KeyModifiers::CONTROL) => + { if !self.threads.is_empty() { self.selected = (self.selected + 1).min(self.threads.len() - 1); } PickerAction::MoveDown } - KeyCode::PageDown => { + KeyCode::PageDown + if key.code == KeyCode::PageDown + || key.modifiers.contains(KeyModifiers::CONTROL) => + { if !self.threads.is_empty() { self.selected = (self.selected + 10).min(self.threads.len() - 1); } @@ -627,7 +637,11 @@ impl PickerState { self.invalidate_thread_list(); PickerAction::Reload } - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::ALT) => { + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { self.query.push(c); self.invalidate_thread_list(); PickerAction::Reload @@ -708,8 +722,9 @@ impl PickerState { 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; + match pager.overlay.handle_event(event) { + PagerAction::Close => self.transcript_pager = None, + PagerAction::Consumed | PagerAction::LoadOlderHistory => {} } } } @@ -951,8 +966,9 @@ async fn run_session_picker_with_action( 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; + match pager.overlay.handle_event(&event) { + PagerAction::Close => picker.transcript_pager = None, + PagerAction::Consumed | PagerAction::LoadOlderHistory => {} } continue; } @@ -1022,109 +1038,44 @@ pub(crate) fn render_with_locale(frame: &mut Frame<'_>, picker: &PickerState, lo } let chunks = Layout::default() .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Min(1), - Constraint::Length(2), - ]) + .constraints(picker_vertical_constraints(area.height)) .split(area); - - let title = Paragraph::new(Line::from(vec![ - Span::styled( - format!( - " {}: {} ", - locale.resume_label(), - locale.resume_picker_title(matches!(picker.action, SessionPickerAction::Fork)) - ), - Style::default().add_modifier(Modifier::BOLD), - ), - Span::styled(locale.resume_title(), Style::default().fg(Color::DarkGray)), - ])); - frame.render_widget(title, chunks[0]); - - let search = if picker.query.is_empty() { - locale.resume_search_placeholder().to_string() - } else { - format!("{}: {}", locale.resume_search_placeholder(), picker.query) + let chrome = |rect: ratatui::layout::Rect| { + ratatui::layout::Rect::new( + rect.x.saturating_add(1), + rect.y, + rect.width.saturating_sub(2), + rect.height, + ) }; - let toolbar = format!( - "{} | {} | {}", - locale.resume_status_label(picker.status == SessionStatus::Archived), - locale.resume_filter_label(picker.show_all), - locale.resume_sort_label(picker.sort_key == ThreadSortKey::CreatedAt), - ); + + let header = chrome(chunks[0]); + let title = locale + .resume_picker_title(matches!(picker.action, SessionPickerAction::Fork)) + .to_string(); frame.render_widget( Paragraph::new(Line::from(Span::styled( - format!("{search} | {toolbar}"), - Style::default().fg(Color::DarkGray), + truncate_display(&title, usize::from(header.width)), + Style::default().add_modifier(Modifier::BOLD), ))), - chunks[0].inner(ratatui::layout::Margin { - vertical: 1, - horizontal: 0, - }), + header, ); - let items = picker - .threads - .iter() - .enumerate() - .map(|(index, thread)| { - let preview = picker.transcript_preview_text(&thread.id); - let title = thread_line_with_preview( - thread, - usize::from(chunks[1].width), - locale, - preview.as_deref(), - ); - let is_expanded = picker.selected == index - && picker.expanded_thread_id.as_deref() == Some(thread.id.as_str()); - if is_expanded { - let mut lines = vec![title]; - lines.extend(render_expanded_session_details( - thread, - picker, - usize::from(chunks[1].width), - locale, - )); - ListItem::new(lines) - } else if picker.density == SessionListDensity::Dense { - ListItem::new(title) - } else { - let metadata = format!( - " {} {}", - thread.cwd.display(), - if thread.updated_at == 0 { - String::from("-") - } else { - thread.updated_at.to_string() - } - ); - ListItem::new(vec![ - title, - Line::from(Span::styled( - truncate_display(&metadata, usize::from(chunks[1].width)), - Style::default().fg(Color::DarkGray), - )), - ]) - } - }) - .collect::>(); - let mut state = ListState::default(); - if !picker.threads.is_empty() { - state.select(Some(picker.selected)); - } - frame.render_stateful_widget( - List::new(items) - .block(Block::default().borders(Borders::TOP | Borders::BOTTOM)) - .highlight_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol("> "), - chunks[1], - &mut state, + let search = chrome(chunks[2]); + frame.render_widget( + Paragraph::new(resume_search_line(picker, locale, search.width)) + .style(Style::default().fg(Color::DarkGray)), + search, ); + + let list = ratatui::layout::Rect::new( + chunks[4].x.saturating_add(2), + chunks[4].y, + chunks[4].width.saturating_sub(PICKER_LIST_HORIZONTAL_INSET), + chunks[4].height, + ); + render_session_list(frame, list, picker, locale); + let footer = if picker.loading { locale.resume_loading().to_string() } else if picker.threads.is_empty() { @@ -1144,14 +1095,278 @@ pub(crate) fn render_with_locale(frame: &mut Frame<'_>, picker: &PickerState, lo locale.resume_density_label(picker.density == SessionListDensity::Dense), ) }; + render_picker_footer(frame, chunks[5], picker, &footer); +} + +/// Allocate the picker chrome without allowing the footer to push the list outside the +/// terminal. The picker is commonly rendered in a full-screen area, but tests and embedded +/// hosts also exercise very short terminals, so every constraint is derived from the height. +fn picker_vertical_constraints(height: u16) -> [Constraint; 6] { + let mut remaining = height; + let header = remaining.min(1); + remaining = remaining.saturating_sub(header); + let header_gap = remaining.min(1); + remaining = remaining.saturating_sub(header_gap); + let search = remaining.min(1); + remaining = remaining.saturating_sub(search); + let list_gap = remaining.min(1); + remaining = remaining.saturating_sub(list_gap); + let footer = remaining.min(4); + remaining = remaining.saturating_sub(footer); + [ + Constraint::Length(header), + Constraint::Length(header_gap), + Constraint::Length(search), + Constraint::Length(list_gap), + Constraint::Length(remaining), + Constraint::Length(footer), + ] +} + +fn resume_search_line(picker: &PickerState, locale: Locale, width: u16) -> Line<'static> { + let width = usize::from(width); + let search = if picker.query.is_empty() { + locale.resume_search_placeholder().to_string() + } else { + format!("{}: {}", locale.resume_search_placeholder(), picker.query) + }; + let toolbar_wide = format!( + "{} | {} | {}", + locale.resume_status_label(picker.status == SessionStatus::Archived), + locale.resume_filter_label(picker.show_all), + locale.resume_sort_label(picker.sort_key == ThreadSortKey::CreatedAt), + ); + let toolbar_compact = format!( + "{} {} {}", + locale.resume_status_label(picker.status == SessionStatus::Archived), + locale.resume_filter_label(picker.show_all), + locale.resume_sort_label(picker.sort_key == ThreadSortKey::CreatedAt), + ); + let separator = " "; + let toolbar = if display_width(&search) + .saturating_add(display_width(separator)) + .saturating_add(display_width(&toolbar_wide)) + <= width + { + toolbar_wide + } else { + toolbar_compact + }; + let available_search = width + .saturating_sub(display_width(&toolbar)) + .saturating_sub(display_width(separator)); + if available_search == 0 { + return Line::from(Span::raw(truncate_display(&toolbar, width))); + } + let search = truncate_display(&search, available_search); + let combined = format!("{search}{separator}{toolbar}"); + Line::from(Span::raw(truncate_display(&combined, width))) +} + +fn render_picker_footer( + frame: &mut Frame<'_>, + area: ratatui::layout::Rect, + picker: &PickerState, + footer: &str, +) { + if area.width == 0 || area.height == 0 { + return; + } + let progress = format!( + " {} / {} ", + if picker.threads.is_empty() { + 0 + } else { + picker.selected.saturating_add(1) + }, + picker.threads.len(), + ); + let progress_width = display_width(&progress); + let separator_width = usize::from(area.width).saturating_sub(progress_width); + let separator = if progress_width < usize::from(area.width) { + format!("{}{}", "─".repeat(separator_width), progress) + } else { + "─".repeat(usize::from(area.width)) + }; + frame.render_widget( + Paragraph::new(separator).style(Style::default().fg(Color::DarkGray)), + ratatui::layout::Rect::new(area.x, area.y, area.width, 1), + ); + if area.height == 1 { + return; + } + let hints = ratatui::layout::Rect::new( + area.x.saturating_add(1), + area.y.saturating_add(1), + area.width.saturating_sub(2), + area.height.saturating_sub(1), + ); frame.render_widget( Paragraph::new(footer) .style(Style::default().fg(Color::DarkGray)) .wrap(ratatui::widgets::Wrap { trim: true }), - chunks[2], + hints, ); } +fn render_session_list( + frame: &mut Frame<'_>, + area: ratatui::layout::Rect, + picker: &PickerState, + locale: Locale, +) { + if area.width == 0 || area.height == 0 { + return; + } + frame.render_widget(Clear, area); + if picker.threads.is_empty() { + frame.render_widget( + Paragraph::new(locale.resume_empty()).style(Style::default().fg(Color::DarkGray)), + area, + ); + return; + } + + let selected = picker.selected.min(picker.threads.len().saturating_sub(1)); + let show_more_above = selected > 0; + let show_more_below = picker.has_more_pages() || selected + 1 < picker.threads.len(); + let content_height = usize::from(area.height) + .saturating_sub(usize::from(show_more_above)) + .saturating_sub(usize::from(show_more_below)) + .max(1); + let mut start = selected; + let selected_height = session_list_item( + &picker.threads[selected], + picker, + selected, + locale, + area.width, + ) + .len() + .max(1); + let mut used = selected_height.min(content_height); + while start > 0 { + let previous_height = session_list_item( + &picker.threads[start - 1], + picker, + start - 1, + locale, + area.width, + ) + .len() + .max(1); + let separator = usize::from(picker.density == SessionListDensity::Comfortable); + if used + separator + previous_height > content_height { + break; + } + start -= 1; + used += separator + previous_height; + } + + let mut y = area.y; + if show_more_above { + frame.render_widget( + Paragraph::new("↑ more").style(Style::default().fg(Color::DarkGray)), + ratatui::layout::Rect::new(area.x, y, area.width, 1), + ); + y = y.saturating_add(1); + } + for index in start..picker.threads.len() { + let content_bottom = area.bottom().saturating_sub(u16::from(show_more_below)); + if y >= content_bottom { + break; + } + for line in session_list_item(&picker.threads[index], picker, index, locale, area.width) { + if y >= content_bottom { + break; + } + frame.render_widget( + Paragraph::new(line), + ratatui::layout::Rect::new(area.x, y, area.width, 1), + ); + y = y.saturating_add(1); + } + if picker.density == SessionListDensity::Comfortable + && y < content_bottom + && index + 1 < picker.threads.len() + { + y = y.saturating_add(1); + } + } + if show_more_below { + frame.render_widget( + Paragraph::new("↓ more").style(Style::default().fg(Color::DarkGray)), + ratatui::layout::Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1), + ); + } +} + +fn session_list_item( + thread: &Thread, + picker: &PickerState, + index: usize, + locale: Locale, + width: u16, +) -> Vec> { + let width = usize::from(width); + let preview = picker.transcript_preview_text(&thread.id); + let title = + thread_line_with_preview(thread, width.saturating_sub(2), locale, preview.as_deref()); + let marker = if picker.selected == index { + Span::styled( + "❯ ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + } else { + Span::raw(" ") + }; + let mut title = Line { + style: title.style, + alignment: title.alignment, + spans: std::iter::once(marker).chain(title.spans).collect(), + }; + if picker.selected == index { + title = title.style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + } + let is_expanded = picker.selected == index + && picker.expanded_thread_id.as_deref() == Some(thread.id.as_str()); + if is_expanded { + let mut lines = vec![title]; + lines.extend(render_expanded_session_details( + thread, + picker, + width.saturating_sub(2), + locale, + )); + lines + } else if picker.density == SessionListDensity::Dense { + vec![title] + } else { + let metadata = format!( + " {} {}", + thread.cwd.display(), + if thread.updated_at == 0 { + String::from("-") + } else { + thread.updated_at.to_string() + } + ); + vec![ + title, + Line::from(Span::styled( + truncate_display(&metadata, width), + Style::default().fg(Color::DarkGray), + )), + ] + } +} + fn render_expanded_session_details( thread: &Thread, picker: &PickerState, @@ -1195,25 +1410,34 @@ fn render_expanded_session_details( match picker.transcripts.get(&thread.id) { Some(SessionTranscriptState::Loading) => { lines.push(Line::from(Span::styled( - format!("{indent}{}", locale.resume_transcript_loading()), + truncate_display( + &format!("{indent}{}", locale.resume_transcript_loading()), + width, + ), Style::default().fg(Color::DarkGray), ))); } Some(SessionTranscriptState::Failed) => { lines.push(Line::from(Span::styled( - format!("{indent}{}", locale.resume_transcript_failed()), + truncate_display( + &format!("{indent}{}", locale.resume_transcript_failed()), + width, + ), Style::default().fg(Color::Red), ))); } Some(SessionTranscriptState::Loaded(entries)) if entries.is_empty() => { lines.push(Line::from(Span::styled( - format!("{indent}{}", locale.resume_transcript_empty()), + truncate_display( + &format!("{indent}{}", locale.resume_transcript_empty()), + width, + ), Style::default().fg(Color::DarkGray), ))); } Some(SessionTranscriptState::Loaded(entries)) => { lines.push(Line::from(Span::styled( - format!("{indent}{}", locale.transcript_title()), + truncate_display(&format!("{indent}{}", locale.transcript_title()), width), Style::default().add_modifier(Modifier::BOLD), ))); for entry in entries { @@ -1236,6 +1460,15 @@ fn render_expanded_session_details( fn metadata_line(label: &str, value: &str, width: usize) -> Line<'static> { let prefix = format!(" {label}: "); + if width == 0 { + return Line::default(); + } + if display_width(&prefix) >= width { + return Line::from(Span::styled( + truncate_display(&prefix, width), + Style::default().fg(Color::DarkGray), + )); + } let available = width.saturating_sub(display_width(&prefix)); Line::from(vec![ Span::styled(prefix, Style::default().fg(Color::DarkGray)), @@ -1277,6 +1510,9 @@ fn thread_line_with_preview( } fn truncate_display(text: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } if display_width(text) <= max_width { return text.to_string(); } @@ -1383,6 +1619,86 @@ mod tests { ); } + #[test] + fn searchable_picker_keeps_plain_vim_letters_for_query_input() { + let mut picker = PickerState::new( + vec![ + thread("one", "first", false), + thread("two", "second", false), + ], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('j'), + KeyModifiers::NONE, + ))), + PickerAction::Reload + ); + assert_eq!(picker.query, "j"); + assert_eq!(picker.selected, 0); + } + + #[test] + fn picker_control_navigation_matches_codex_list_bindings() { + let mut picker = PickerState::new( + vec![ + thread("one", "first", false), + thread("two", "second", false), + thread("three", "third", false), + ], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('n'), + KeyModifiers::CONTROL, + ))), + PickerAction::MoveDown + ); + assert_eq!(picker.selected, 1); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('p'), + KeyModifiers::CONTROL, + ))), + PickerAction::MoveUp + ); + assert_eq!(picker.selected, 0); + assert_eq!( + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::PageDown, + KeyModifiers::NONE, + ))), + PickerAction::MoveDown + ); + assert_eq!(picker.selected, 2); + } + + #[test] + fn unhandled_control_keys_do_not_pollute_resume_query() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::Char('z'), + KeyModifiers::CONTROL, + ))); + assert!(picker.query.is_empty()); + } + #[test] fn picker_render_is_bounded_for_narrow_unicode_terminal() { let picker = PickerState::new( @@ -1401,6 +1717,160 @@ mod tests { <= 1)); } + #[test] + fn resume_picker_render_matrix_stays_bounded_across_locales_and_widths() { + let mut threads = (0..12) + .map(|index| { + let mut value = thread( + &format!("thread-{index:02}"), + "这是一个很长的会话预览,用于验证窄终端上的重排与截断行为", + false, + ); + value.name = Some(format!( + "会话 {index:02} — a deliberately long title for the picker" + )); + value.cwd = PathBuf::from( + "/Users/example/Documents/projects/a-very-long-working-directory", + ); + value + }) + .collect::>(); + let selected_id = threads[0].id.clone(); + threads.reverse(); + + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + for width in [40u16, 80, 120] { + let mut picker = PickerState::new( + threads.clone(), + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.query = "a very long query that must remain clipped safely".to_string(); + picker.selected = picker + .threads + .iter() + .position(|thread| thread.id == selected_id) + .unwrap_or_default(); + let selected_lines = session_list_item( + &picker.threads[picker.selected], + &picker, + picker.selected, + locale, + width, + ); + assert!( + selected_lines + .iter() + .all(|line| line.width() <= usize::from(width)), + "locale={} width={width} selected row exceeded width: {selected_lines:?}", + locale.tag() + ); + + let mut terminal = Terminal::new(TestBackend::new(width, 16)).expect("terminal"); + terminal + .draw(|frame| render_with_locale(frame, &picker, locale)) + .expect("draw"); + let rendered = buffer_text(&terminal); + assert_eq!(rendered.lines().count(), 16); + assert!( + rendered.contains("00"), + "locale={} width={width} selected row was not visible: {rendered:?}", + locale.tag() + ); + assert!( + rendered.contains("Enter") && rendered.contains("Esc"), + "locale={} width={width} footer lost primary actions: {rendered:?}", + locale.tag() + ); + assert!( + terminal.backend().buffer().content().iter().all(|cell| cell + .symbol() + .chars() + .count() + <= 1), + "locale={} width={width} contains a multi-codepoint cell", + locale.tag() + ); + } + } + } + + #[test] + fn resume_picker_short_terminal_heights_remain_renderable() { + let picker = PickerState::new( + vec![thread("one", "preview", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + for height in [1u16, 2, 4, 7, 8] { + let mut terminal = Terminal::new(TestBackend::new(40, height)).expect("terminal"); + terminal + .draw(|frame| render_with_locale(frame, &picker, Locale::EnUs)) + .expect("draw"); + assert_eq!(terminal.backend().buffer().area.height, height); + } + } + + #[test] + fn resume_search_line_preserves_toolbar_and_display_width_on_narrow_terminals() { + let mut picker = PickerState::new( + vec![thread("one", "first", false)], + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.query = "a very long search query that should be shortened".to_string(); + for width in [8, 16, 24, 40] { + let line = resume_search_line(&picker, Locale::EnUs, width); + assert!( + crate::line_truncation::line_width(&line) <= usize::from(width), + "width={width} line={line:?}" + ); + } + } + + #[test] + fn long_resume_list_keeps_selected_session_visible_after_end_navigation() { + let mut threads = (0..20) + .map(|index| { + let mut value = thread(&format!("thread-{index:02}"), "preview", false); + value.name = Some(format!("Session {index:02}")); + value + }) + .collect::>(); + threads.reverse(); + let mut picker = PickerState::new( + threads, + SessionPickerAction::Resume, + SessionStatus::Active, + None, + true, + ); + picker.handle_event(Event::Key(crossterm::event::KeyEvent::new( + KeyCode::End, + KeyModifiers::NONE, + ))); + + let mut terminal = Terminal::new(TestBackend::new(48, 10)).expect("terminal"); + terminal.draw(|frame| render(frame, &picker)).expect("draw"); + let text = buffer_text(&terminal); + assert!( + text.contains("Session 00"), + "selected session was clipped from the viewport: {text}" + ); + } + #[test] fn truncation_preserves_display_width() { let text = truncate_display("你好abc", 5); diff --git a/lime-rs/crates/tui/src/runtime.rs b/lime-rs/crates/tui/src/runtime.rs index b1c6ed8a6..2ecf46175 100644 --- a/lime-rs/crates/tui/src/runtime.rs +++ b/lime-rs/crates/tui/src/runtime.rs @@ -109,6 +109,8 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { let mut app = App::default(); app.set_cwd(options.cwd.clone()); app.set_locale(Locale::resolve(options.locale.as_deref())); + app.composer + .set_agents_navigation_enabled(options.remote.is_none()); app.begin_startup_input_boundary(); let setup_result = crate::app::startup::initialize_session( &options, @@ -176,6 +178,11 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { .set_status("reconnect unavailable: thread id missing"); } } + if session.is_some() { + if let Some(delay) = app.bottom_pane.next_frame_delay(std::time::Instant::now()) { + frame_requester.schedule_frame_in(delay); + } + } terminal .sync_viewport() .context("failed to synchronize terminal viewport")?; @@ -320,7 +327,7 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { if app.projection.active_turn_id().is_some() { frame_requester.schedule_frame_in(Duration::from_secs(1)); } - continue; + TuiEvent::Draw } TuiEvent::Resize(size) => { terminal.update_viewport(size, size.height); @@ -692,6 +699,25 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { } AppAction::ScrollTop => app.scroll_top(), AppAction::ScrollBottom => app.scroll_bottom(), + AppAction::LoadOlderHistory => { + let result = app + .request_all_older_history_pages( + session.as_mut().expect( + "session available during transcript history loading", + ), + ) + .await; + match result { + Ok(_) => { + if let Some(pager) = app.pager_overlay.as_ref() { + pager.reset_transcript_anchor_at_top(); + } + } + Err(error) => app + .projection + .set_status(format!("history page failed: {error}")), + } + } AppAction::OpenResumePicker => { if app.resume_picker.is_none() { let (load_tx, load_rx) = tokio::sync::mpsc::unbounded_channel(); @@ -864,7 +890,8 @@ pub async fn run_tui(options: TuiOptions) -> Result<()> { | AppAction::RefreshAgentsOverview | AppAction::DispatchAgentsOverviewTask { .. } | AppAction::RenameAgentsOverviewThread { .. } - | AppAction::StopAgentsOverviewThread { .. } => { + | AppAction::StopAgentsOverviewThread { .. } + | AppAction::FetchMcpInventory { .. } => { unreachable!("App Server actions are handled by app::event_dispatch") } AppAction::Quit => break, diff --git a/lime-rs/crates/tui/src/runtime_pty_tests.rs b/lime-rs/crates/tui/src/runtime_pty_tests.rs index 2e9f98f20..cfd36902b 100644 --- a/lime-rs/crates/tui/src/runtime_pty_tests.rs +++ b/lime-rs/crates/tui/src/runtime_pty_tests.rs @@ -125,7 +125,12 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { 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)); + wait_for_marker( + &output_rx, + &mut output, + "Ask Lime to do anything", + Duration::from_secs(10), + ); if scenario == "agents-overview" { writer .write_all(b"/agents\r") @@ -240,7 +245,7 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { &output_rx, &mut output, return_to_composer_at, - "ready", + "Ask Lime to do anything", Duration::from_secs(10), ); writer @@ -381,10 +386,10 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { ); writer.write_all(b"\x1b").expect("interrupt with Escape"); writer.flush().expect("flush Escape interrupt"); - wait_for_marker( + wait_for_any_marker( &output_rx, &mut output, - "interrupting", + &["interrupting", "interrupted"], Duration::from_secs(5), ); wait_for_ledger_kind_and_scenario( @@ -441,8 +446,9 @@ fn real_pty_restores_terminal_after_visible_turn_completion() { "terminal result was not visible" ); if scenario == "interrupt" { + let visible = visible_terminal_text(&output); assert!( - visible_terminal_text(&output).contains("interrupting"), + visible.contains("interrupting") || visible.contains("interrupted"), "interrupt action was not rendered" ); assert!( @@ -574,6 +580,28 @@ fn wait_for_marker( } } +fn wait_for_any_marker( + output_rx: &mpsc::Receiver>, + output: &mut String, + markers: &[&str], + timeout: Duration, +) { + let deadline = Instant::now() + timeout; + while !markers + .iter() + .any(|marker| visible_terminal_text(output).contains(marker)) + { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + panic!("timed out waiting for one of {markers:?}; output: {output}"); + } + let chunk = output_rx + .recv_timeout(remaining) + .unwrap_or_else(|_| panic!("PTY closed before one of {markers:?}; output: {output}")); + output.push_str(&String::from_utf8_lossy(&chunk)); + } +} + fn wait_for_marker_after( output_rx: &mpsc::Receiver>, output: &mut String, diff --git a/lime-rs/crates/tui/src/settings.rs b/lime-rs/crates/tui/src/settings.rs index a5248ffaf..3d4e7f3a5 100644 --- a/lime-rs/crates/tui/src/settings.rs +++ b/lime-rs/crates/tui/src/settings.rs @@ -49,6 +49,7 @@ pub(crate) fn parse_settings_command(prompt: &str) -> Option None, } diff --git a/lime-rs/crates/tui/src/slash_command.rs b/lime-rs/crates/tui/src/slash_command.rs index d33f59f4a..16cb3b82e 100644 --- a/lime-rs/crates/tui/src/slash_command.rs +++ b/lime-rs/crates/tui/src/slash_command.rs @@ -13,11 +13,12 @@ pub(crate) enum SlashCommand { Agents, MultiAgents, Resume, + Mcp, Vim, } impl SlashCommand { - pub(crate) const ALL: [Self; 12] = [ + pub(crate) const ALL: [Self; 13] = [ Self::Model, Self::Plan, Self::Effort, @@ -29,6 +30,7 @@ impl SlashCommand { Self::Agents, Self::MultiAgents, Self::Resume, + Self::Mcp, Self::Vim, ]; @@ -45,6 +47,7 @@ impl SlashCommand { Self::Agents => "agents", Self::MultiAgents => "subagents", Self::Resume => "resume", + Self::Mcp => "mcp", Self::Vim => "vim", } } @@ -110,6 +113,7 @@ mod tests { "agents", "subagents", "resume", + "mcp", "vim", ] ); diff --git a/lime-rs/crates/tui/src/status_indicator.rs b/lime-rs/crates/tui/src/status_indicator.rs index ac957f816..a48d2133e 100644 --- a/lime-rs/crates/tui/src/status_indicator.rs +++ b/lime-rs/crates/tui/src/status_indicator.rs @@ -1,40 +1,17 @@ use std::time::Duration; 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; pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, locale: Locale, elapsed: Duration) { - if area.is_empty() { - return; - } - - frame.render_widget( - Paragraph::new(status_line(locale, elapsed, area.width)), - area, - ); + crate::status_indicator_widget::render(frame, area, locale, elapsed); } fn status_line(locale: Locale, elapsed: Duration, width: u16) -> Line<'static> { - truncate_line_with_ellipsis_if_overflow( - Line::styled( - format!( - "• {} ({} • {})", - locale.working_label(), - fmt_elapsed_compact(elapsed.as_secs()), - locale.interrupt_hint() - ), - Style::default() - .fg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ), - usize::from(width), - ) + crate::status_indicator_widget::StatusIndicatorWidget::new(locale, elapsed).status_line(width) } fn fmt_elapsed_compact(elapsed_secs: u64) -> String { diff --git a/lime-rs/crates/tui/src/status_indicator_widget.rs b/lime-rs/crates/tui/src/status_indicator_widget.rs index 7442e1ad5..1ba4c1377 100644 --- a/lime-rs/crates/tui/src/status_indicator_widget.rs +++ b/lime-rs/crates/tui/src/status_indicator_widget.rs @@ -1,13 +1,262 @@ +//! Live status row rendered above the composer while an agent turn is running. +//! +//! The widget owns presentation and width decisions for elapsed time, interrupt hints, inline +//! context, and hook activity. It consumes display-ready values from `App` and the canonical +//! projection; it does not read provider state or create a second runtime state machine. + use std::time::Duration; use ratatui::layout::Rect; +use ratatui::style::Modifier; +use ratatui::text::{Line, Span, Text}; +use ratatui::widgets::Paragraph; use ratatui::Frame; +use crate::line_truncation::{ + line_width, truncate_line_to_width, truncate_line_with_ellipsis_if_overflow, +}; use crate::locale::Locale; -use crate::status_indicator; +use crate::style::muted_style; +use crate::width::display_width; +use crate::wrapping::{word_wrap_lines, RtOptions}; +#[allow(dead_code)] +#[path = "status_indicator_widget/timer.rs"] +mod timer; +#[allow(dead_code, unused_imports)] +pub(crate) use timer::StatusTimer; + +pub(crate) const STATUS_DETAILS_DEFAULT_MAX_LINES: usize = 3; +const DETAILS_PREFIX: &str = " └ "; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[allow(dead_code)] +pub(crate) enum StatusDetailsCapitalization { + CapitalizeFirst, + Preserve, +} + +/// Displays a live status row with optional wrapped details. +/// +/// App owns the timer and projection state, while this type owns presentation and width/reflow +/// decisions. The same renderer is used by the regular view and TestBackend/PTY evidence. +#[derive(Debug, Clone)] +pub(crate) struct StatusIndicatorWidget { + header: String, + elapsed: Duration, + interrupt_hint: String, + details: Option, + details_max_lines: usize, + inline_message: Option, + hook_status_message: Option, + show_interrupt_hint: bool, +} + +#[allow(dead_code)] +impl StatusIndicatorWidget { + pub(crate) fn new(locale: Locale, elapsed: Duration) -> Self { + Self { + header: locale.working_label().to_string(), + elapsed, + interrupt_hint: locale.interrupt_hint().to_string(), + details: None, + details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES, + inline_message: None, + hook_status_message: None, + show_interrupt_hint: true, + } + } + + pub(crate) fn update_header(&mut self, header: impl Into) { + self.header = header.into(); + } + + pub(crate) fn update_details( + &mut self, + details: Option, + capitalization: StatusDetailsCapitalization, + max_lines: usize, + ) { + self.details_max_lines = max_lines.max(1); + self.details = details + .filter(|details| !details.trim().is_empty()) + .map(|details| { + let trimmed = details.trim_start(); + match capitalization { + StatusDetailsCapitalization::CapitalizeFirst => { + crate::text_formatting::capitalize_first(trimmed) + } + StatusDetailsCapitalization::Preserve => trimmed.to_string(), + } + }); + } + + pub(crate) fn update_inline_message(&mut self, message: Option) { + self.inline_message = message + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()); + } + + pub(crate) fn update_hook_status_message(&mut self, message: Option) { + self.hook_status_message = message + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()); + } + + pub(crate) fn set_interrupt_hint_visible(&mut self, visible: bool) { + self.show_interrupt_hint = visible; + } + + pub(crate) fn set_interrupt_hint(&mut self, hint: impl Into) { + self.interrupt_hint = hint.into(); + } + + pub(crate) fn desired_height(&self, width: u16) -> u16 { + self.lines(width).len().try_into().unwrap_or(u16::MAX) + } + + pub(crate) fn render(&self, area: Rect, frame: &mut Frame<'_>) { + if area.is_empty() { + return; + } + frame.render_widget(Paragraph::new(Text::from(self.lines(area.width))), area); + } + + pub(crate) fn status_line(&self, width: u16) -> Line<'static> { + self.lines(width) + .into_iter() + .next() + .unwrap_or_else(|| Line::from(Vec::>::new())) + } + + fn lines(&self, width: u16) -> Vec> { + if width == 0 { + return Vec::new(); + } + + let elapsed = fmt_elapsed_compact(self.elapsed.as_secs()); + let style = muted_style().add_modifier(Modifier::BOLD); + let mut spans = vec![Span::styled(format!("• {}", self.header), style)]; + if self.show_interrupt_hint { + spans.push(Span::styled( + format!(" ({elapsed} • {})", self.interrupt_hint), + style, + )); + } else { + spans.push(Span::styled(format!(" ({elapsed})"), style)); + } + if let Some(message) = &self.inline_message { + spans.extend([ + Span::styled(" · ", muted_style()), + Span::styled(message.clone(), muted_style()), + ]); + } + + let mut header = Line::from(spans); + let mut overflow = None; + if let Some(message) = &self.hook_status_message { + let suffix_width = display_width(" · ") + display_width(message); + if line_width(&header) + suffix_width <= usize::from(width) { + header.spans.extend([ + Span::styled(" · ", muted_style()), + Span::styled(message.clone(), muted_style()), + ]); + } else { + overflow = Some(truncate_line_with_ellipsis_if_overflow( + Line::from(vec![ + Span::styled(DETAILS_PREFIX, muted_style()), + Span::styled(message.clone(), muted_style()), + ]), + usize::from(width), + )); + } + } + + let mut lines = vec![truncate_line_with_ellipsis_if_overflow( + header, + usize::from(width), + )]; + lines.extend(overflow); + lines.extend(self.wrapped_details_lines(width)); + lines + } + + fn wrapped_details_lines(&self, width: u16) -> Vec> { + let Some(details) = self.details.as_deref() else { + return Vec::new(); + }; + if width == 0 { + return Vec::new(); + } + + let prefix_width = display_width(DETAILS_PREFIX); + let dim = muted_style(); + let options = RtOptions::new(usize::from(width)) + .initial_indent(Line::from(Span::styled(DETAILS_PREFIX, dim))) + .subsequent_indent(Line::from(Span::styled(" ".repeat(prefix_width), dim))) + .break_words(true); + let mut lines = word_wrap_lines( + details.lines().map(|line| vec![Span::styled(line, dim)]), + options, + ); + + if lines.len() > self.details_max_lines { + lines.truncate(self.details_max_lines); + let content_width = usize::from(width).saturating_sub(prefix_width).max(1); + let max_base_width = content_width.saturating_sub(1); + if let Some(last) = lines.last_mut() { + let content = last + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + let truncated = truncate_line_to_width(Line::from(content), max_base_width); + let text = truncated + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + *last = Line::from(vec![ + Span::styled(" ".repeat(prefix_width), dim), + Span::styled(format!("{text}…"), dim), + ]); + } + } + lines + } +} + +#[allow(dead_code)] pub(crate) fn render(frame: &mut Frame<'_>, area: Rect, locale: Locale, elapsed: Duration) { - status_indicator::render(frame, area, locale, elapsed); + let widget = StatusIndicatorWidget::new(locale, elapsed); + widget.render(area, frame); +} + +pub(crate) fn render_with_messages( + frame: &mut Frame<'_>, + area: Rect, + locale: Locale, + elapsed: Duration, + inline_message: Option<&str>, + hook_status_message: Option<&str>, +) { + let mut widget = StatusIndicatorWidget::new(locale, elapsed); + widget.update_inline_message(inline_message.map(ToOwned::to_owned)); + widget.update_hook_status_message(hook_status_message.map(ToOwned::to_owned)); + widget.render(area, frame); +} + +pub(crate) fn desired_height_with_messages( + width: u16, + locale: Locale, + elapsed: Duration, + inline_message: Option<&str>, + hook_status_message: Option<&str>, +) -> u16 { + let mut widget = StatusIndicatorWidget::new(locale, elapsed); + widget.update_inline_message(inline_message.map(ToOwned::to_owned)); + widget.update_hook_status_message(hook_status_message.map(ToOwned::to_owned)); + widget.desired_height(width) } pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { @@ -24,3 +273,191 @@ pub fn fmt_elapsed_compact(elapsed_secs: u64) -> String { elapsed_secs % 60 ) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::line_truncation::line_width; + + #[test] + fn elapsed_time_matches_codex_compact_format() { + assert_eq!(fmt_elapsed_compact(0), "0s"); + assert_eq!(fmt_elapsed_compact(59), "59s"); + assert_eq!(fmt_elapsed_compact(60), "1m 00s"); + assert_eq!(fmt_elapsed_compact(3_599), "59m 59s"); + assert_eq!(fmt_elapsed_compact(3_600), "1h 00m 00s"); + assert_eq!(fmt_elapsed_compact(7_389), "2h 03m 09s"); + } + + #[test] + fn hook_status_moves_to_a_second_line_when_the_header_does_not_fit() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.update_hook_status_message(Some("checking 日本語 policy".to_string())); + assert_eq!(widget.desired_height(80), 1); + assert_eq!(widget.desired_height(24), 2); + } + + #[test] + fn details_are_wrapped_and_bounded_by_max_lines() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.update_details( + Some("cargo test -p tui and then cargo test -p cli".to_string()), + StatusDetailsCapitalization::Preserve, + 1, + ); + assert_eq!(widget.desired_height(24), 2); + let lines = widget.wrapped_details_lines(24); + assert_eq!(lines.len(), 1); + assert!(line_width(lines.first().expect("details line")) <= 24); + assert!(lines + .first() + .expect("details line") + .to_string() + .ends_with('…')); + } + + #[test] + fn renders_without_spinner_when_animations_disabled() { + let widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + let line = widget.status_line(80).to_string(); + assert!(line.starts_with("• Working (0s • esc to interrupt)")); + } + + #[test] + fn renders_remapped_interrupt_hint() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.set_interrupt_hint("F12 to interrupt"); + assert!(widget + .status_line(80) + .to_string() + .contains("F12 to interrupt")); + } + + #[test] + fn hook_status_reflows_without_displacing_controls_or_details() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.update_hook_status_message(Some("checking 日本語 ガパ policy".to_string())); + widget.update_details( + Some("existing details".to_string()), + StatusDetailsCapitalization::Preserve, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + + let expected = "• Working (0s • esc to interrupt) · checking 日本語 ガパ policy"; + let fit_width = display_width(expected) as u16; + assert_eq!(widget.desired_height(fit_width), 2); + assert_eq!(widget.desired_height(fit_width.saturating_sub(1)), 3); + + widget.update_inline_message(Some( + "1 background terminal running · /ps to view · /stop to close".to_string(), + )); + assert!(widget.desired_height(fit_width) >= 2); + } + + #[test] + fn details_overflow_adds_ellipsis() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.update_details( + Some("abcd abcd abcd abcd".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + let lines = widget.wrapped_details_lines(6); + assert_eq!(lines.len(), STATUS_DETAILS_DEFAULT_MAX_LINES); + assert!(lines + .last() + .expect("last details line") + .to_string() + .ends_with('…')); + } + + #[test] + fn details_args_can_disable_capitalization_and_limit_lines() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::ZERO); + widget.update_details( + Some("cargo test -p tui and then cargo test -p cli".to_string()), + StatusDetailsCapitalization::Preserve, + 1, + ); + assert_eq!( + widget.details.as_deref(), + Some("cargo test -p tui and then cargo test -p cli") + ); + let lines = widget.wrapped_details_lines(24); + assert_eq!(lines.len(), 1); + assert!(lines + .last() + .expect("details line") + .to_string() + .ends_with('…')); + } + + #[test] + fn localized_status_keeps_interrupt_hint_and_width_bound() { + for (locale, working, interrupt) in [ + (Locale::ZhCn, "处理中", "Esc 中断"), + (Locale::ZhTw, "處理中", "Esc 中斷"), + (Locale::EnUs, "Working", "esc to interrupt"), + (Locale::JaJp, "処理中", "Esc で中断"), + (Locale::KoKr, "작업 중", "Esc로 중단"), + ] { + let widget = StatusIndicatorWidget::new(locale, Duration::from_secs(63)); + let line = widget.status_line(80).to_string(); + assert!(line.contains(working), "{locale:?}: {line}"); + assert!(line.contains(interrupt), "{locale:?}: {line}"); + assert!(line_width(&widget.status_line(18)) <= 18); + } + } + + #[test] + fn active_status_height_and_width_are_bounded_for_supported_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + for width in [40, 80, 120] { + let mut widget = StatusIndicatorWidget::new(locale, Duration::from_secs(12)); + widget.update_inline_message(Some("queue editing".to_string())); + widget.update_hook_status_message(Some("checking 日本語 policy".to_string())); + let lines = widget.lines(width); + assert!( + !lines.is_empty(), + "missing status for {locale:?} at {width}" + ); + assert!( + lines + .iter() + .all(|line| line_width(line) <= usize::from(width)), + "status overflow for {locale:?} at {width}: {lines:?}" + ); + assert_eq!(widget.desired_height(width), lines.len() as u16); + } + } + } + + #[test] + fn hook_and_details_reflow_to_distinct_rows_without_dropping_the_header() { + let mut widget = StatusIndicatorWidget::new(Locale::EnUs, Duration::from_secs(12)); + widget.update_hook_status_message(Some("checking policy".to_string())); + widget.update_details( + Some("a long running command with useful details".to_string()), + StatusDetailsCapitalization::Preserve, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + + let lines = widget.lines(40); + assert!( + lines.len() >= 3, + "expected header, hook and details: {lines:?}" + ); + assert!(lines[0].to_string().contains("Working")); + assert!(lines + .iter() + .skip(1) + .any(|line| line.to_string().contains("checking"))); + assert!(lines.iter().skip(1).all(|line| line_width(line) <= 40)); + } +} diff --git a/lime-rs/crates/tui/src/status_indicator_widget/timer.rs b/lime-rs/crates/tui/src/status_indicator_widget/timer.rs new file mode 100644 index 000000000..35898bd0e --- /dev/null +++ b/lime-rs/crates/tui/src/status_indicator_widget/timer.rs @@ -0,0 +1,60 @@ +//! Pause-aware elapsed time for the live status indicator. +//! +//! The timer is intentionally independent from turn accounting. App/runtime may pause the +//! display while an approval or user-input request is visible without changing canonical turn +//! state. + +use std::time::{Duration, Instant}; + +#[derive(Debug, Clone)] +pub(crate) struct StatusTimer { + pub(crate) display_started_at: Option, + elapsed_running: Duration, + last_resume_at: Instant, + is_paused: bool, +} + +impl Default for StatusTimer { + fn default() -> Self { + Self { + display_started_at: None, + elapsed_running: Duration::ZERO, + last_resume_at: Instant::now(), + is_paused: false, + } + } +} + +impl StatusTimer { + pub(crate) fn reset(&mut self, elapsed: Duration) { + self.elapsed_running = elapsed; + self.last_resume_at = Instant::now(); + } + + pub(crate) fn pause_at(&mut self, now: Instant) { + if !self.is_paused { + self.elapsed_running += now.saturating_duration_since(self.last_resume_at); + self.is_paused = true; + } + } + + pub(crate) fn resume_at(&mut self, now: Instant) { + if self.is_paused { + self.last_resume_at = now; + self.is_paused = false; + } + } + + pub(crate) fn elapsed_at(&self, now: Instant) -> Duration { + self.elapsed_running + + if self.is_paused { + Duration::ZERO + } else { + now.saturating_duration_since(self.last_resume_at) + } + } +} + +#[cfg(test)] +#[path = "timer_tests.rs"] +mod tests; diff --git a/lime-rs/crates/tui/src/status_indicator_widget/timer_tests.rs b/lime-rs/crates/tui/src/status_indicator_widget/timer_tests.rs new file mode 100644 index 000000000..d3369eded --- /dev/null +++ b/lime-rs/crates/tui/src/status_indicator_widget/timer_tests.rs @@ -0,0 +1,34 @@ +use super::*; +use std::time::{Duration, Instant}; + +#[test] +fn pauses_and_resumes_without_counting_wait_time() { + let baseline = Instant::now(); + let mut timer = StatusTimer { + last_resume_at: baseline, + ..StatusTimer::default() + }; + timer.pause_at(baseline + Duration::from_secs(5)); + timer.pause_at(baseline + Duration::from_secs(8)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(10)), + Duration::from_secs(5) + ); + timer.resume_at(baseline + Duration::from_secs(10)); + timer.resume_at(baseline + Duration::from_secs(12)); + assert_eq!( + timer.elapsed_at(baseline + Duration::from_secs(13)), + Duration::from_secs(8) + ); + timer.reset(Duration::from_secs(24)); + assert_eq!( + timer.elapsed_at(timer.last_resume_at), + Duration::from_secs(24) + ); + timer.pause_at(timer.last_resume_at); + timer.reset(Duration::ZERO); + assert_eq!( + timer.elapsed_at(timer.last_resume_at + Duration::from_secs(120)), + Duration::ZERO + ); +} diff --git a/lime-rs/crates/tui/src/style.rs b/lime-rs/crates/tui/src/style.rs new file mode 100644 index 000000000..ee221e925 --- /dev/null +++ b/lime-rs/crates/tui/src/style.rs @@ -0,0 +1,256 @@ +//! Semantic styles shared by Lime TUI surfaces. + +use ratatui::style::{Color, Style}; + +use crate::terminal_palette::{ + best_color_for_level, default_bg, default_fg, effective_stdout_color_level, rgb_color, + stdout_color_level, StdoutColorLevel, +}; + +const LIGHT_BG_ACCENT_RGB: (u8, u8, u8) = (0, 95, 135); +const TABLE_SEPARATOR_FG_ALPHA: f32 = 0.20; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StatusTone { + Success, + Attention, + Failure, +} + +pub(crate) fn status_style(tone: StatusTone) -> Style { + status_style_for(tone, default_bg(), effective_stdout_color_level()) +} + +pub(crate) fn attention_style() -> Style { + status_style(StatusTone::Attention) +} + +pub(crate) fn failure_style() -> Style { + status_style(StatusTone::Failure) +} + +pub(crate) fn accent_style() -> Style { + accent_style_for(default_bg(), effective_stdout_color_level()) +} + +pub(crate) fn muted_style() -> Style { + Style::default().dim() +} + +pub(crate) fn user_message_style() -> Style { + user_message_style_for(default_bg(), effective_stdout_color_level()) +} + +pub(crate) fn table_separator_style() -> Style { + table_separator_style_for(default_fg(), default_bg(), stdout_color_level()) +} + +pub(crate) fn footer_hint_label_style() -> Style { + footer_hint_label_style_for(default_bg(), effective_stdout_color_level()) +} + +fn status_style_for( + tone: StatusTone, + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, +) -> Style { + let base = Style::default().bold(); + if color_level == StdoutColorLevel::Unknown { + return base; + } + + let light = terminal_bg.is_some_and(is_light); + match tone { + StatusTone::Success => base.fg(Color::Green), + StatusTone::Failure => base.fg(Color::Red), + StatusTone::Attention if light || terminal_bg.is_none() => base, + StatusTone::Attention => base.fg(Color::Yellow), + } +} + +fn accent_style_for(terminal_bg: Option<(u8, u8, u8)>, color_level: StdoutColorLevel) -> Style { + let base = Style::default().bold(); + if color_level == StdoutColorLevel::Unknown { + return base; + } + if terminal_bg.is_some_and(is_light) { + base.fg(best_color_for_level(LIGHT_BG_ACCENT_RGB, color_level)) + } else { + base.fg(Color::Cyan) + } +} + +fn user_message_style_for( + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, +) -> Style { + let Some(background) = terminal_bg else { + return Style::default(); + }; + if matches!( + color_level, + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown + ) { + return Style::default(); + } + Style::default().bg(best_color_for_level( + user_message_bg_rgb(background), + color_level, + )) +} + +fn table_separator_style_for( + terminal_fg: Option<(u8, u8, u8)>, + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, +) -> Style { + let (Some(foreground), Some(background)) = (terminal_fg, terminal_bg) else { + return muted_style(); + }; + let separator = blend(foreground, background, TABLE_SEPARATOR_FG_ALPHA); + match color_level { + StdoutColorLevel::TrueColor => Style::default().fg(rgb_color(separator)), + StdoutColorLevel::Ansi256 => { + Style::default().fg(best_color_for_level(separator, color_level)) + } + StdoutColorLevel::Ansi16 | StdoutColorLevel::Unknown => muted_style(), + } +} + +fn footer_hint_label_style_for( + terminal_bg: Option<(u8, u8, u8)>, + color_level: StdoutColorLevel, +) -> Style { + if color_level != StdoutColorLevel::Unknown && terminal_bg.is_some_and(is_light) { + Style::default().fg(Color::DarkGray) + } else { + muted_style() + } +} + +fn user_message_bg_rgb(background: (u8, u8, u8)) -> (u8, u8, u8) { + let (foreground, alpha) = if is_light(background) { + ((0, 0, 0), 0.04) + } else { + ((255, 255, 255), 0.12) + }; + blend(foreground, background, alpha) +} + +fn is_light((red, green, blue): (u8, u8, u8)) -> bool { + let luminance = 0.299 * f32::from(red) + 0.587 * f32::from(green) + 0.114 * f32::from(blue); + luminance > 128.0 +} + +fn blend(foreground: (u8, u8, u8), background: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) { + let channel = |foreground: u8, background: u8| { + (f32::from(foreground) * alpha + f32::from(background) * (1.0 - alpha)) as u8 + }; + ( + channel(foreground.0, background.0), + channel(foreground.1, background.1), + channel(foreground.2, background.2), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::style::Modifier; + + #[test] + fn status_tones_preserve_light_dark_and_no_color_semantics() { + assert_eq!( + status_style_for( + StatusTone::Attention, + Some((0, 0, 0)), + StdoutColorLevel::Ansi16, + ) + .fg, + Some(Color::Yellow), + ); + assert_eq!( + status_style_for( + StatusTone::Attention, + Some((255, 255, 255)), + StdoutColorLevel::TrueColor, + ) + .fg, + None, + ); + for tone in [ + StatusTone::Success, + StatusTone::Attention, + StatusTone::Failure, + ] { + let style = status_style_for(tone, Some((0, 0, 0)), StdoutColorLevel::Unknown); + assert_eq!(style.fg, None); + assert!(style.add_modifier.contains(Modifier::BOLD)); + } + } + + #[test] + fn accent_is_palette_aware_and_has_a_no_color_fallback() { + assert_eq!( + accent_style_for(Some((0, 0, 0)), StdoutColorLevel::Ansi16).fg, + Some(Color::Cyan), + ); + assert!(matches!( + accent_style_for(Some((255, 255, 255)), StdoutColorLevel::TrueColor).fg, + Some(Color::Rgb(0, 95, 135)), + )); + assert_eq!( + accent_style_for(Some((0, 0, 0)), StdoutColorLevel::Unknown).fg, + None, + ); + } + + #[test] + fn user_message_surface_adapts_to_dark_light_and_no_color() { + assert_eq!( + user_message_style_for(Some((0, 0, 0)), StdoutColorLevel::TrueColor).bg, + Some(Color::Rgb(30, 30, 30)), + ); + assert_eq!( + user_message_style_for(Some((255, 255, 255)), StdoutColorLevel::TrueColor).bg, + Some(Color::Rgb(244, 244, 244)), + ); + assert_eq!( + user_message_style_for(Some((0, 0, 0)), StdoutColorLevel::Unknown).bg, + None, + ); + } + + #[test] + fn table_separator_blends_on_truecolor_and_dims_without_palette_support() { + assert_eq!( + table_separator_style_for( + Some((255, 255, 255)), + Some((0, 0, 0)), + StdoutColorLevel::TrueColor, + ) + .fg, + Some(Color::Rgb(51, 51, 51)), + ); + assert!(table_separator_style_for( + Some((255, 255, 255)), + Some((0, 0, 0)), + StdoutColorLevel::Unknown, + ) + .add_modifier + .contains(Modifier::DIM),); + } + + #[test] + fn footer_styles_keep_labels_quiet_on_all_palettes() { + assert_eq!( + footer_hint_label_style_for(Some((255, 255, 255)), StdoutColorLevel::TrueColor,).fg, + Some(Color::DarkGray), + ); + assert!( + footer_hint_label_style_for(Some((0, 0, 0)), StdoutColorLevel::Unknown) + .add_modifier + .contains(Modifier::DIM), + ); + } +} diff --git a/lime-rs/crates/tui/src/terminal_palette.rs b/lime-rs/crates/tui/src/terminal_palette.rs index 172c4588e..9057f936a 100644 --- a/lime-rs/crates/tui/src/terminal_palette.rs +++ b/lime-rs/crates/tui/src/terminal_palette.rs @@ -17,20 +17,24 @@ pub(crate) enum StdoutColorLevel { #[allow(dead_code)] pub(crate) fn stdout_color_level() -> StdoutColorLevel { - if std::env::var_os("NO_COLOR").is_some() { - return StdoutColorLevel::Ansi16; + stdout_color_level_for_env( + std::env::var_os("NO_COLOR").is_some(), + &std::env::var("COLORTERM").unwrap_or_default(), + &std::env::var("TERM").unwrap_or_default(), + ) +} + +fn stdout_color_level_for_env(no_color: bool, color_term: &str, term: &str) -> StdoutColorLevel { + if no_color { + return StdoutColorLevel::Unknown; } - let color_term = std::env::var("COLORTERM") - .unwrap_or_default() - .to_ascii_lowercase(); + let color_term = color_term.to_ascii_lowercase(); if matches!(color_term.as_str(), "truecolor" | "24bit") { return StdoutColorLevel::TrueColor; } - let term = std::env::var("TERM") - .unwrap_or_default() - .to_ascii_lowercase(); + let term = term.to_ascii_lowercase(); if term.contains("direct") || term.contains("truecolor") { StdoutColorLevel::TrueColor } else if term.contains("256color") { @@ -316,4 +320,12 @@ mod tests { assert_eq!(default_fg(), None); assert_eq!(default_bg(), None); } + + #[test] + fn no_color_disables_semantic_colors_without_mutating_process_env() { + assert_eq!( + stdout_color_level_for_env(true, "truecolor", "xterm-256color"), + StdoutColorLevel::Unknown, + ); + } } diff --git a/lime-rs/crates/tui/src/transcript_reflow.rs b/lime-rs/crates/tui/src/transcript_reflow.rs index c2bbeb4c8..15c37aa61 100644 --- a/lime-rs/crates/tui/src/transcript_reflow.rs +++ b/lime-rs/crates/tui/src/transcript_reflow.rs @@ -4,8 +4,13 @@ //! projection; this module only records observed/rebuilt widths and ensures a //! resize during streaming receives one final source-backed repaint. +use std::cell::{Cell, RefCell}; use std::time::{Duration, Instant}; +use ratatui::layout::Rect; + +use crate::terminal_hyperlinks::{HyperlinkLine, HyperlinkParagraph}; + pub(crate) const TRANSCRIPT_REFLOW_DEBOUNCE: Duration = Duration::from_millis(75); #[derive(Debug, Default)] @@ -112,6 +117,144 @@ pub(crate) struct TranscriptWidthChange { pub(crate) initialized: bool, } +/// Keeps the main transcript viewport anchored while canonical content is replaced or grows. +/// +/// `App::transcript_scroll` remains the user-facing distance-from-bottom input contract. This +/// state records the last rendered canonical lines and resolves that relative request into an +/// absolute wrapped-row offset when a stream delta or resize changes the rendered height. +#[derive(Debug, Default)] +pub(crate) struct TranscriptViewport { + previous_lines: RefCell>>, + previous_width: Cell, + previous_offset: Cell, + previous_requested_scroll: Cell>, +} + +impl TranscriptViewport { + pub(crate) fn clear(&self) { + *self.previous_lines.borrow_mut() = None; + self.previous_width.set(0); + self.previous_offset.set(0); + self.previous_requested_scroll.set(None); + } + + pub(crate) fn resolve( + &self, + lines: &[HyperlinkLine], + area: Rect, + requested_distance_from_bottom: usize, + ) -> u16 { + if area.width == 0 || area.height == 0 { + return 0; + } + + let paragraph = HyperlinkParagraph::new(lines); + let total_height = paragraph.line_count(area.width); + let max_scroll = total_height.saturating_sub(usize::from(area.height)); + let base_offset = max_scroll.saturating_sub(requested_distance_from_bottom.min(max_scroll)); + let requested_is_unchanged = + self.previous_requested_scroll.get() == Some(requested_distance_from_bottom); + let mut offset = base_offset; + + if requested_distance_from_bottom > 0 && requested_is_unchanged { + if let Some(previous) = self.previous_lines.borrow().as_ref().cloned() { + let old_width = self.previous_width.get(); + if old_width > 0 { + if let Some(mapped) = remap_transcript_offset( + &previous, + old_width, + self.previous_offset.get(), + lines, + area.width, + ) { + offset = mapped.min(max_scroll); + } else if previous == lines { + // A height-only resize has no logical content change. Keep the same top row + // and clamp it to the new page bounds. + offset = self.previous_offset.get().min(max_scroll); + } + } + } + } + + *self.previous_lines.borrow_mut() = Some(lines.to_vec()); + self.previous_width.set(area.width); + self.previous_offset.set(offset); + self.previous_requested_scroll + .set(Some(requested_distance_from_bottom)); + u16::try_from(offset).unwrap_or(u16::MAX) + } +} + +fn remap_transcript_offset( + previous: &[HyperlinkLine], + old_width: u16, + old_offset: usize, + lines: &[HyperlinkLine], + width: u16, +) -> Option { + if previous.is_empty() || lines.is_empty() || old_width == 0 || width == 0 { + return None; + } + + let old_starts = wrapped_line_starts(previous, old_width); + let (old_line, intra_line_offset) = old_starts + .iter() + .enumerate() + .rev() + .find(|(_, start)| **start <= old_offset) + .map(|(index, start)| (index, old_offset.saturating_sub(*start))) + .unwrap_or((0, old_offset)); + + let common_prefix = previous + .iter() + .zip(lines) + .take_while(|(old, new)| old == new) + .count(); + let common_suffix = previous + .iter() + .rev() + .zip(lines.iter().rev()) + .take_while(|(old, new)| old == new) + .count() + .min(previous.len().saturating_sub(common_prefix)); + + let mapped_line = if lines.len() == previous.len() + && (common_prefix > 0 || common_suffix > 0 || previous.len() == 1) + { + old_line + } else if lines.len() >= previous.len() && lines[lines.len() - previous.len()..] == previous[..] + { + old_line + lines.len() - previous.len() + } else if lines.len() >= previous.len() && lines[..previous.len()] == previous[..] { + old_line + } else { + return None; + }; + + let new_starts = wrapped_line_starts(lines, width); + let new_start = *new_starts.get(mapped_line)?; + let new_height = new_starts + .get(mapped_line + 1) + .copied() + .unwrap_or_else(|| HyperlinkParagraph::new(&lines[mapped_line..]).line_count(width)); + Some(new_start + intra_line_offset.min(new_height.saturating_sub(1))) +} + +fn wrapped_line_starts(lines: &[HyperlinkLine], width: u16) -> Vec { + let mut starts = Vec::with_capacity(lines.len()); + let mut offset = 0usize; + for line in lines { + starts.push(offset); + offset = offset.saturating_add( + HyperlinkParagraph::new(std::slice::from_ref(line)) + .line_count(width) + .max(1), + ); + } + starts +} + #[cfg(test)] mod tests { use super::*; @@ -147,4 +290,77 @@ mod tests { assert!(state.take_stream_finish_reflow_needed()); assert!(!state.take_stream_finish_reflow_needed()); } + + #[test] + fn viewport_preserves_manual_anchor_when_stream_tail_grows() { + let viewport = TranscriptViewport::default(); + let area = Rect::new(0, 0, 18, 3); + let initial = vec![ + HyperlinkLine::from("header"), + HyperlinkLine::from("anchor"), + HyperlinkLine::from("tail"), + HyperlinkLine::from("tail 2"), + HyperlinkLine::from("tail 3"), + HyperlinkLine::from("tail 4"), + HyperlinkLine::from("tail 5"), + HyperlinkLine::from("tail 6"), + ]; + + assert_eq!(viewport.resolve(&initial, area, 4), 1); + + let updated = vec![ + HyperlinkLine::from("header"), + HyperlinkLine::from("anchor"), + HyperlinkLine::from("tail that grew while streaming"), + HyperlinkLine::from("tail 2"), + HyperlinkLine::from("tail 3"), + HyperlinkLine::from("tail 4"), + HyperlinkLine::from("tail 5"), + HyperlinkLine::from("tail 6"), + ]; + + assert_eq!(viewport.resolve(&updated, area, 4), 1); + } + + #[test] + fn viewport_keeps_pinned_bottom_following_new_content() { + let viewport = TranscriptViewport::default(); + let area = Rect::new(0, 0, 18, 3); + let initial = vec![ + HyperlinkLine::from("one"), + HyperlinkLine::from("two"), + HyperlinkLine::from("three"), + HyperlinkLine::from("four"), + ]; + let first = viewport.resolve(&initial, area, 0); + let updated = [ + HyperlinkLine::from("one"), + HyperlinkLine::from("two"), + HyperlinkLine::from("three"), + HyperlinkLine::from("four"), + HyperlinkLine::from("five"), + ]; + let second = viewport.resolve(&updated, area, 0); + assert!(second > first); + } + + #[test] + fn viewport_remaps_manual_anchor_when_width_reflows() { + let viewport = TranscriptViewport::default(); + let initial = vec![ + HyperlinkLine::from("a long header that fits on one wide row"), + HyperlinkLine::from("anchor"), + HyperlinkLine::from("tail 1"), + HyperlinkLine::from("tail 2"), + HyperlinkLine::from("tail 3"), + HyperlinkLine::from("tail 4"), + HyperlinkLine::from("tail 5"), + ]; + let wide = Rect::new(0, 0, 48, 3); + viewport.resolve(&initial, wide, 3); + + let narrow = Rect::new(0, 0, 16, 3); + let resolved = viewport.resolve(&initial, narrow, 3); + assert_eq!(usize::from(resolved), wrapped_line_starts(&initial, 16)[1]); + } } diff --git a/lime-rs/crates/tui/src/view.rs b/lime-rs/crates/tui/src/view.rs index 67ca3bccd..a535426a6 100644 --- a/lime-rs/crates/tui/src/view.rs +++ b/lime-rs/crates/tui/src/view.rs @@ -1,15 +1,15 @@ use ratatui::layout::{Constraint, Direction, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Clear, FrameExt as _, Paragraph}; +use ratatui::widgets::{Clear, FrameExt as _, Paragraph}; use ratatui::Frame; use crate::app::App; use crate::bottom_pane; -use crate::command_popup; +use crate::bottom_pane::command_popup; +use crate::bottom_pane::pending_input_preview; use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::model_picker; -use crate::pending_input_preview; use crate::status_indicator_widget; use crate::terminal_hyperlinks::HyperlinkParagraph; use std::time::Instant; @@ -34,31 +34,41 @@ pub(crate) fn render(frame: &mut Frame<'_>, app: &App) { crate::app::transcript_export::render_picker(frame, area, picker, app.locale); return; } - let chunks = screen_chunks(area, app); + let active_elapsed = app.active_turn_elapsed(Instant::now()); + let chunks = screen_chunks(area, app, active_elapsed); - render_header(frame, chunks[0], app); - render_transcript(frame, chunks[1], app); + render_transcript(frame, chunks.transcript, app); if !app.bottom_pane.is_active() { - if let Some(elapsed) = app.active_turn_elapsed(Instant::now()) { - status_indicator_widget::render(frame, chunks[2], app.locale, elapsed); + if let Some(elapsed) = active_elapsed { + let inline_status = active_status_message(app); + status_indicator_widget::render_with_messages( + frame, + chunks.status, + app.locale, + elapsed, + inline_status.as_deref(), + app.projection.hook_status_message(), + ); + } else if let Some(status) = transient_status(app) { + render_transient_status(frame, chunks.status, app.locale, &status); } - pending_input_preview::render(frame, chunks[3], &app.queued_submissions, app.locale); + pending_input_preview::render(frame, chunks.preview, &app.queued_submissions, app.locale); } if app.bottom_pane.is_active() { - bottom_pane::render_with_locale(frame, chunks[4], &app.bottom_pane, app.locale); + bottom_pane::render_with_locale(frame, chunks.input, &app.bottom_pane, app.locale); } else { - render_composer(frame, chunks[4], app); + render_composer(frame, chunks.input, app); } - bottom_pane::render_footer(frame, chunks[5], app); + bottom_pane::render_footer(frame, chunks.footer, app); if !app.bottom_pane.is_active() { if let Some(popup) = app.composer.command_popup() { - command_popup::render(frame, chunks[4], popup, app.locale); + command_popup::render(frame, chunks.input, popup, app.locale); } if let Some(popup) = app.composer.file_search_popup() { - popup.render(frame, chunks[4], app.locale); + popup.render(frame, chunks.input, app.locale); } if let Some(popup) = app.composer.skill_popup() { - popup.render(frame, chunks[4], app.locale); + popup.render(frame, chunks.input, app.locale); } } if let Some(picker) = app.model_picker.as_ref() { @@ -66,6 +76,11 @@ pub(crate) fn render(frame: &mut Frame<'_>, app: &App) { } if let Some(overview) = app.agents_overview.as_ref() { crate::app::agents_overview_view::render(frame, area, &overview.view, app.locale); + // Keep app-scoped action feedback visible while the centered overview owns the main + // viewport. Its popup intentionally leaves the footer row available for this status. + if let Some(status) = transient_status(app) { + render_transient_status(frame, chunks.footer, app.locale, &status); + } } if let Some(picker) = app.agent_picker.as_ref() { if app.agents_overview.is_none() { @@ -74,9 +89,36 @@ pub(crate) fn render(frame: &mut Frame<'_>, app: &App) { } } -fn screen_chunks(area: Rect, app: &App) -> [Rect; 6] { - let status_height = - u16::from(!app.bottom_pane.is_active() && app.projection.active_turn_id().is_some()); +#[derive(Debug, Clone, Copy)] +struct ScreenChunks { + transcript: Rect, + status: Rect, + preview: Rect, + input: Rect, + footer: Rect, +} + +fn screen_chunks( + area: Rect, + app: &App, + active_elapsed: Option, +) -> ScreenChunks { + let status_height = if app.bottom_pane.is_active() { + 0 + } else if let Some(elapsed) = active_elapsed { + let inline_status = active_status_message(app); + status_indicator_widget::desired_height_with_messages( + area.width, + app.locale, + elapsed, + inline_status.as_deref(), + app.projection.hook_status_message(), + ) + } else if transient_status(app).is_some() { + 1 + } else { + 0 + }; let preview_height = if app.bottom_pane.is_active() { 0 } else { @@ -85,26 +127,25 @@ fn screen_chunks(area: Rect, app: &App) -> [Rect; 6] { .min(area.height.saturating_sub(6 + status_height)) }; let input_height = if app.bottom_pane.is_active() { - bottom_pane::desired_height_with_locale(&app.bottom_pane, app.locale) + bottom_pane::desired_height_with_locale_for_width(&app.bottom_pane, app.locale, area.width) } else { let desired = app .composer .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); + .saturating_add(1) + .clamp(2, 12); desired.min( area.height .saturating_sub(preview_height) .saturating_sub(status_height) - .saturating_sub(3) + .saturating_sub(2) .max(1), ) }; let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(1), Constraint::Min(1), Constraint::Length(status_height), Constraint::Length(preview_height), @@ -112,33 +153,45 @@ fn screen_chunks(area: Rect, app: &App) -> [Rect; 6] { Constraint::Length(1), ]) .split(area); - [ - chunks[0], chunks[1], chunks[2], chunks[3], chunks[4], chunks[5], - ] + ScreenChunks { + transcript: chunks[0], + status: chunks[1], + preview: chunks[2], + input: chunks[3], + footer: chunks[4], + } } -pub(crate) fn transcript_page_size(width: u16, height: u16, app: &App) -> usize { - let transcript = screen_chunks(Rect::new(0, 0, width, height), app)[1]; - usize::from(transcript.height.saturating_sub(1).max(1)) +fn transient_status(app: &App) -> Option { + let status = app.status_value(); + if status.is_empty() || status == "ready" { + None + } else { + Some(status) + } } -fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { - let status = status_text(app); - let model = app.model.as_deref().unwrap_or("auto"); - let effort = app.reasoning_effort.as_deref().unwrap_or("auto"); - let permissions = app.permissions.as_deref().unwrap_or("default"); - let suffix = format!( - " {}:{model} {}:{effort} {}:{permissions}", - app.locale.model_label(), - app.locale.effort_label(), - app.locale.permissions_label() - ); +fn active_status_message(app: &App) -> Option { + let status = transient_status(app)?; + if status == "running" { + None + } else { + Some(app.locale.status(&status)) + } +} + +fn render_transient_status( + frame: &mut Frame<'_>, + area: Rect, + locale: crate::locale::Locale, + status: &str, +) { + if area.is_empty() { + return; + } let line = Line::from(vec![ - Span::styled(" Lime ", Style::default().add_modifier(Modifier::BOLD)), - Span::styled( - format!("{}{suffix}", status), - Style::default().fg(Color::DarkGray), - ), + Span::styled("• ", crate::style::accent_style()), + Span::styled(locale.status(status), crate::style::muted_style()), ]); frame.render_widget( Paragraph::new(truncate_line_with_ellipsis_if_overflow( @@ -149,17 +202,26 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { ); } +pub(crate) fn transcript_page_size(width: u16, height: u16, app: &App) -> usize { + let transcript = screen_chunks( + Rect::new(0, 0, width, height), + app, + app.active_turn_elapsed(Instant::now()), + ) + .transcript; + usize::from(transcript.height.saturating_sub(1).max(1)) +} + fn render_transcript(frame: &mut Frame<'_>, area: Rect, app: &App) { let lines = crate::app::history_ui::render_transcript_content_lines(app, area.width, false); let paragraph = HyperlinkParagraph::new(&lines); - let scroll = transcript_scroll_offset( - paragraph.line_count(area.width), - area, - app.transcript_scroll, - ); + let scroll = app + .transcript_viewport + .resolve(&lines, area, app.transcript_scroll); frame.render_widget(paragraph.scroll(scroll), area); } +#[cfg(test)] fn transcript_scroll_offset( rendered_line_count: usize, area: Rect, @@ -171,11 +233,7 @@ fn transcript_scroll_offset( } fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { - let block = Block::default() - .borders(Borders::TOP | Borders::BOTTOM) - .border_style(Style::default().fg(Color::DarkGray)); - let inner = block.inner(area); - frame.render_widget(block, area); + let inner = area; let remote_count = app.composer.remote_image_urls().len(); let mut image_lines = app.composer.remote_image_lines(); @@ -203,10 +261,11 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { frame.render_widget(Paragraph::new(image_lines), image_area); } + let prompt_width = 2_u16.min(inner.width); let text_area = Rect::new( - inner.x, + inner.x.saturating_add(prompt_width), inner.y.saturating_add(image_height), - inner.width, + inner.width.saturating_sub(prompt_width), inner.height.saturating_sub(image_height), ); if text_area.is_empty() { @@ -227,8 +286,24 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { ) }) .collect::>(); + let prompt = Line::from(Span::styled("› ", crate::style::accent_style())); + frame.render_widget( + Paragraph::new(prompt), + Rect::new(inner.x, text_area.y, prompt_width, 1), + ); if highlights.is_empty() { - frame.render_stateful_widget_ref(app.composer.textarea(), text_area, &mut *state); + if app.composer.is_empty() { + // Keep attachment rows visible above the input baseline. The prompt occupies its + // own gutter, while the placeholder is rendered inside the text area so both + // share the same input baseline. + let placeholder = Line::from(Span::styled( + app.locale.composer_placeholder(), + crate::style::muted_style(), + )); + frame.render_widget(Paragraph::new(placeholder), text_area); + } else { + frame.render_stateful_widget_ref(app.composer.textarea(), text_area, &mut *state); + } } else { app.composer.textarea().render_ref_styled_with_highlights( text_area, @@ -247,13 +322,6 @@ fn render_composer(frame: &mut Frame<'_>, area: Rect, app: &App) { } } -fn status_text(app: &App) -> String { - match app.status_value().as_str() { - "" => app.locale.ready_label().to_string(), - status => app.locale.status(status), - } -} - #[cfg(test)] mod tests { use super::*; @@ -308,11 +376,11 @@ mod tests { fn transcript_page_size_tracks_resize() { let mut app = App::default(); - assert_eq!(transcript_page_size(80, 10, &app), 4); - assert_eq!(transcript_page_size(80, 6, &app), 1); + assert_eq!(transcript_page_size(80, 10, &app), 6); + assert_eq!(transcript_page_size(80, 6, &app), 2); app.attach_image(std::path::PathBuf::from("/tmp/one.png")); app.attach_image(std::path::PathBuf::from("/tmp/two.png")); - assert_eq!(transcript_page_size(80, 10, &app), 2); + assert_eq!(transcript_page_size(80, 10, &app), 4); } fn buffer_text(terminal: &Terminal) -> String { @@ -345,7 +413,7 @@ mod tests { }, )); app.composer.insert("继续"); - let mut terminal = Terminal::new(TestBackend::new(80, 10)).expect("terminal"); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("draw"); @@ -356,8 +424,12 @@ mod tests { assert!(text.contains("terminal")); assert!(text.contains('继')); assert!(text.contains('续')); - assert!(text.contains("model:fixture-model")); - assert!(text.contains("effort:high")); + let compact = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!(compact.contains("model:fixture-model")); + assert!(compact.contains("high")); } #[test] @@ -376,6 +448,45 @@ mod tests { assert!(text.contains("describe these")); } + #[test] + fn idle_composer_uses_codex_prompt_and_localized_placeholder() { + let mut app = App::default(); + app.set_locale(Locale::EnUs); + let mut terminal = Terminal::new(TestBackend::new(80, 10)).expect("terminal"); + + terminal.draw(|frame| render(frame, &app)).expect("draw"); + + let text = buffer_text(&terminal); + assert!(text.contains("› "), "missing composer prompt: {text}"); + assert!( + text.contains("Ask Lime to do anything"), + "missing composer placeholder: {text}" + ); + } + + #[test] + fn transient_status_keeps_action_feedback_visible_without_idle_status_bar() { + let mut app = App::default(); + app.projection.set_status("interrupting"); + let mut terminal = Terminal::new(TestBackend::new(80, 10)).expect("terminal"); + + terminal.draw(|frame| render(frame, &app)).expect("draw"); + + let text = buffer_text(&terminal); + assert!( + text.contains("• interrupting"), + "missing transient status: {text}" + ); + assert!( + text.contains("› Ask Lime to do anything"), + "missing composer: {text}" + ); + assert!( + !text.contains("Lime ready"), + "idle header leaked into status: {text}" + ); + } + #[test] fn test_backend_renders_remote_images_with_selection_highlight() { let mut app = App::default(); @@ -501,6 +612,78 @@ mod tests { assert!(text.contains('…'), "{text}"); } + #[test] + fn status_and_footer_geometry_remains_stable_across_supported_widths_and_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + for width in [40, 80, 120] { + let mut app = App::default(); + app.set_locale(locale); + app.start_turn(format!("turn-{width}")); + app.set_queued_submissions(vec![QueuedSubmission { + id: format!("queue-{width}"), + input: vec![UserInput::Text { + text: "queued follow-up".to_string(), + text_elements: Vec::new(), + }], + client_user_message_id: format!("client-{width}"), + }]); + app.composer.insert("draft"); + + let height = 20; + let mut terminal = + Terminal::new(TestBackend::new(width, height)).expect("terminal"); + terminal.draw(|frame| render(frame, &app)).expect("draw"); + let text = buffer_text(&terminal); + assert_eq!(text.lines().count(), usize::from(height)); + let compact = text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!( + compact.contains('›'), + "missing composer for {locale:?} at {width}: {text}" + ); + assert!( + compact.contains( + &locale + .working_label() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + ), + "missing active status for {locale:?} at {width}: {text}" + ); + assert!( + compact.contains( + &locale + .status("queued") + .chars() + .filter(|character| !character.is_whitespace()) + .collect::() + ), + "missing queue preview for {locale:?} at {width}: {text}" + ); + + let chunks = screen_chunks( + Rect::new(0, 0, width, height), + &app, + app.active_turn_elapsed(Instant::now()), + ); + assert!(chunks.transcript.bottom() <= chunks.status.top()); + assert!(chunks.status.bottom() <= chunks.preview.top()); + assert!(chunks.preview.bottom() <= chunks.input.top()); + assert!(chunks.input.bottom() <= chunks.footer.top()); + assert_eq!(chunks.footer.height, 1); + } + } + } + #[test] fn history_search_footer_shows_localized_query_without_hiding_composer() { let mut app = App::default(); @@ -612,7 +795,7 @@ mod tests { terminal .draw(|frame| render(frame, &app)) .expect("draw wide"); - let footer = screen_chunks(wide, &app)[5]; + let footer = screen_chunks(wide, &app, app.active_turn_elapsed(Instant::now())).footer; let prefix_width = Line::from(format!(" {}", app.locale.history_search_label())).width() as u16; assert_eq!( @@ -626,7 +809,7 @@ mod tests { terminal .draw(|frame| render(frame, &app)) .expect("draw narrow"); - let footer = screen_chunks(narrow, &app)[5]; + let footer = screen_chunks(narrow, &app, app.active_turn_elapsed(Instant::now())).footer; assert_eq!( terminal.backend().cursor_position(), Position::new(footer.right().saturating_sub(1), footer.y) @@ -655,9 +838,13 @@ mod tests { .chars() .filter(|character| !character.is_whitespace()) .collect::(); - assert!(text.contains("> /permissions")); + assert!(text.contains("› /permissions")); assert!(compact.contains("设置权限配置"), "{text}"); - assert!(!text.contains("/model")); + assert_eq!( + text.matches("/model").count(), + 1, + "session header only: {text}" + ); assert!(text.contains("/pe")); } @@ -817,7 +1004,7 @@ mod tests { (Locale::JaJp, "モデル:fixture-model", "設定を更新しました"), (Locale::KoKr, "모델:fixture-model", "설정이 업데이트됨"), ]; - for (locale, model_label, status_label) in cases { + for (locale, model_label, _status_label) in cases { let mut app = App::default(); app.set_locale(locale); app.set_settings( @@ -826,8 +1013,8 @@ mod tests { Some("high".to_string()), Some(":workspace".to_string()), ); - app.projection.set_status("settings updated"); - let mut terminal = Terminal::new(TestBackend::new(80, 10)).expect("terminal"); + app.set_cwd(std::path::PathBuf::from("/workspace")); + let mut terminal = Terminal::new(TestBackend::new(80, 16)).expect("terminal"); terminal.draw(|frame| render(frame, &app)).expect("draw"); let text = buffer_text(&terminal); let compact = text @@ -835,11 +1022,7 @@ mod tests { .filter(|character| !character.is_whitespace()) .collect::(); assert!(compact.contains(model_label), "{locale:?}: {text}"); - let compact_status = status_label - .chars() - .filter(|character| !character.is_whitespace()) - .collect::(); - assert!(compact.contains(&compact_status), "{locale:?}: {text}"); + assert!(compact.contains("/workspace"), "{locale:?}: {text}"); } } @@ -1092,7 +1275,14 @@ mod tests { let text = buffer_text(&terminal); assert_eq!(text.lines().count(), 6); - assert!(text.contains("Lime")); + assert!( + text.contains("╰"), + "narrow session header remains bounded: {text}" + ); + assert!( + !text.contains("Lime"), + "narrow header should clip safely: {text}" + ); } #[test] @@ -1164,6 +1354,205 @@ mod tests { assert!(!text.contains("copy the last response")); } + #[test] + fn approval_narrow_layout_keeps_primary_controls_visible() { + let mut app = App::default(); + app.bottom_pane + .enqueue(ServerRequest::ItemCommandExecutionRequestApproval { + id: RequestId::Integer(10), + 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: Some("a long reason that should wrap safely".to_string()), + network_approval_context: None, + command: Some("cargo test -p tui --lib".to_string()), + cwd: Some("/workspace/project".to_string()), + available_decisions: None, + }, + }) + .expect("queue approval"); + let mut terminal = Terminal::new(TestBackend::new(40, 20)).expect("terminal"); + + terminal.draw(|frame| render(frame, &app)).expect("draw"); + + let text = buffer_text(&terminal); + assert!(text.contains("Allow once"), "{text}"); + assert!(text.contains("Enter confirm"), "{text}"); + assert!(text.contains("Esc cancel"), "{text}"); + assert!(text.lines().all(|line| line.chars().count() <= 40)); + } + + #[test] + fn request_user_input_narrow_layout_keeps_submit_and_cancel_visible() { + let mut app = App::default(); + app.bottom_pane + .enqueue(ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(11), + params: ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose the next step for this task".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + app_server_protocol::protocol::v2::ToolRequestUserInputOption { + label: "Run tests".to_string(), + description: "Pick the most relevant crate and validate behavior" + .to_string(), + }, + app_server_protocol::protocol::v2::ToolRequestUserInputOption { + label: "Review diff".to_string(), + description: "Summarize the current changes".to_string(), + }, + ]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + }) + .expect("queue user input"); + let mut terminal = Terminal::new(TestBackend::new(40, 20)).expect("terminal"); + + terminal.draw(|frame| render(frame, &app)).expect("draw"); + + let text = buffer_text(&terminal); + assert!(text.contains("1. Run tests"), "{text}"); + assert!(text.contains("Enter submit"), "{text}"); + assert!(text.contains("Esc cancel"), "{text}"); + assert!(text.lines().all(|line| line.chars().count() <= 40)); + } + + #[test] + fn interactive_overlays_remain_actionable_across_supported_widths_and_locales() { + for locale in [ + Locale::ZhCn, + Locale::ZhTw, + Locale::EnUs, + Locale::JaJp, + Locale::KoKr, + ] { + for width in [40, 80, 120] { + let mut approval = App::default(); + approval.set_locale(locale); + approval + .bottom_pane + .enqueue(ServerRequest::ItemCommandExecutionRequestApproval { + id: RequestId::Integer(12), + 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: Some("focused regression".to_string()), + network_approval_context: None, + command: Some("cargo test -p tui".to_string()), + cwd: Some("/workspace".to_string()), + available_decisions: None, + }, + }) + .expect("queue approval"); + let mut terminal = Terminal::new(TestBackend::new(width, 20)).expect("terminal"); + terminal + .draw(|frame| render(frame, &approval)) + .expect("draw approval"); + let approval_text = buffer_text(&terminal); + let approval_compact = approval_text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!( + approval_compact.contains( + &locale + .approval_title("command") + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(), + ), + "approval title for {locale:?} at {width}: {approval_text}" + ); + assert!( + approval_compact.contains( + &locale + .approval_controls() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(), + ), + "approval controls for {locale:?} at {width}: {approval_text}" + ); + + let mut question = App::default(); + question.set_locale(locale); + question + .bottom_pane + .enqueue(ServerRequest::ItemToolRequestUserInput { + id: RequestId::Integer(13), + params: ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "question-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "mode".to_string(), + header: "Mode".to_string(), + question: "Choose one option".to_string(), + is_other: false, + is_secret: false, + options: Some(vec![ + app_server_protocol::protocol::v2::ToolRequestUserInputOption { + label: "Fast".to_string(), + description: "Continue immediately".to_string(), + }, + ]), + }], + is_blocking: true, + auto_resolution_ms: None, + }, + }) + .expect("queue user input"); + terminal + .draw(|frame| render(frame, &question)) + .expect("draw user input"); + let question_text = buffer_text(&terminal); + let question_compact = question_text + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(); + assert!( + question_compact.contains( + &locale + .request_submit_hint() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(), + ), + "submit control for {locale:?} at {width}: {question_text}" + ); + assert!( + question_compact.contains( + &locale + .request_cancel_hint() + .chars() + .filter(|character| !character.is_whitespace()) + .collect::(), + ), + "cancel control for {locale:?} at {width}: {question_text}" + ); + assert!( + question_compact.contains("1.Fast"), + "option for {locale:?} at {width}: {question_text}" + ); + } + } + } + #[test] fn secret_user_input_is_masked_in_the_test_backend() { let mut app = App::default(); @@ -1182,6 +1571,7 @@ mod tests { is_secret: true, options: None, }], + is_blocking: true, auto_resolution_ms: None, }, }) diff --git a/lime-rs/crates/tui/tests/suite/focus_palette.rs b/lime-rs/crates/tui/tests/suite/focus_palette.rs index c16549482..bff2c056d 100644 --- a/lime-rs/crates/tui/tests/suite/focus_palette.rs +++ b/lime-rs/crates/tui/tests/suite/focus_palette.rs @@ -11,6 +11,12 @@ const FOCUS_INPUT_TIMEOUT: Duration = Duration::from_secs(5); const RESIZE_EVENT_TIMEOUT: Duration = Duration::from_secs(5); const FOCUS_PROBE_INPUT: &str = "focus-palette-24527"; +#[derive(Clone, Copy)] +enum StartMode<'a> { + Tui, + Resume(&'a str), +} + #[test] fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> Result<()> { if std::env::var_os("LIME_TEST_TUI_GATE_B").is_none() { @@ -85,6 +91,46 @@ impl PtyLime { ledger_path: &Path, cwd: &Path, node_bin: &Path, + ) -> Result { + Self::start_with_mode( + cli_bin, + app_server_bin, + backend_path, + ledger_path, + cwd, + node_bin, + StartMode::Tui, + ) + } + + pub(super) fn start_resume( + cli_bin: &Path, + app_server_bin: &Path, + backend_path: &Path, + ledger_path: &Path, + cwd: &Path, + node_bin: &Path, + thread_id: &str, + ) -> Result { + Self::start_with_mode( + cli_bin, + app_server_bin, + backend_path, + ledger_path, + cwd, + node_bin, + StartMode::Resume(thread_id), + ) + } + + fn start_with_mode( + cli_bin: &Path, + app_server_bin: &Path, + backend_path: &Path, + ledger_path: &Path, + cwd: &Path, + node_bin: &Path, + mode: StartMode<'_>, ) -> Result { let pair = native_pty_system().openpty(PtySize { rows: 32, @@ -93,8 +139,15 @@ impl PtyLime { pixel_height: 0, })?; let mut command = CommandBuilder::new(cli_bin); - for argument in [ - OsString::from("tui"), + let mut arguments = Vec::new(); + match mode { + StartMode::Tui => arguments.push(OsString::from("tui")), + StartMode::Resume(thread_id) => { + arguments.push(OsString::from("resume")); + arguments.push(OsString::from(thread_id)); + } + } + arguments.extend([ OsString::from("--cd"), cwd.as_os_str().to_os_string(), OsString::from("--model"), @@ -120,7 +173,8 @@ impl PtyLime { "--app-server-arg={}", cwd.join("app-data").display() )), - ] { + ]); + for argument in arguments { command.arg(argument); } command.cwd(cwd); @@ -163,11 +217,15 @@ impl PtyLime { } pub(super) fn wait_for_startup(&mut self) -> Result<()> { - let deadline = Instant::now() + STARTUP_TIMEOUT; + self.wait_for_startup_with_timeout(STARTUP_TIMEOUT) + } + + pub(super) fn wait_for_startup_with_timeout(&mut self, timeout: Duration) -> Result<()> { + let deadline = Instant::now() + timeout; while Instant::now() < deadline { self.read_output(Duration::from_millis(50))?; self.answer_startup_queries()?; - if self.palette_answered && self.screen_contains("ready") { + if self.palette_answered && self.screen_contains("Ask Lime to do anything") { return Ok(()); } if let Some(status) = self.child.try_wait()? { @@ -175,7 +233,7 @@ impl PtyLime { } } bail!( - "Lime did not initialize within {STARTUP_TIMEOUT:?}; screen:\n{}", + "Lime did not initialize within {timeout:?}; screen:\n{}", self.screen_contents() ) } @@ -328,7 +386,7 @@ impl Drop for PtyLime { } } -fn required_test_path(name: &str) -> PathBuf { +pub(super) fn required_test_path(name: &str) -> PathBuf { std::env::var_os(name) .filter(|value| !value.is_empty()) .map(PathBuf::from) diff --git a/lime-rs/crates/tui/tests/suite/history_pagination.rs b/lime-rs/crates/tui/tests/suite/history_pagination.rs new file mode 100644 index 000000000..c310a363c --- /dev/null +++ b/lime-rs/crates/tui/tests/suite/history_pagination.rs @@ -0,0 +1,87 @@ +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{ensure, Result}; + +use super::focus_palette::PtyLime; + +const STARTUP_TIMEOUT: Duration = Duration::from_secs(30); +const TRANSCRIPT_TIMEOUT: Duration = Duration::from_secs(15); + +/// Exercise the real paginated Thread/Turn/Item path through `lime resume` and a PTY. +/// +/// The fixture seeds more than one item page, a completed review turn, and an interrupted +/// nested turn with duplicate canonical user messages. Home must load the older page and retain +/// the turn-aware filtering/separator decisions across that boundary. +#[test] +fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { + if std::env::var_os("LIME_TEST_TUI_HISTORY_PAGINATION").is_none() { + return Ok(()); + } + + let cli_bin = required_path("LIME_TEST_CLI_BIN"); + let app_server_bin = required_path("LIME_TEST_APP_SERVER_BIN"); + let backend_path = required_path("LIME_TEST_TERMINAL_BACKEND"); + let ledger_path = required_path("LIME_TEST_TERMINAL_LEDGER"); + let cwd = required_path("LIME_TEST_TERMINAL_CWD"); + let node_bin = required_path("LIME_TEST_NODE_BIN"); + let thread_id = required_path("LIME_TEST_TUI_HISTORY_THREAD_ID") + .to_string_lossy() + .into_owned(); + + let mut terminal = PtyLime::start_resume( + &cli_bin, + &app_server_bin, + &backend_path, + &ledger_path, + &cwd, + &node_bin, + &thread_id, + )?; + terminal.wait_for_startup_with_timeout(STARTUP_TIMEOUT)?; + + // Ctrl-T opens the complete transcript. Home is sent as the terminal's canonical ESC [ H + // sequence; the pager requests an older page only when the scroll reaches its top. + terminal.write_input(&[0x14])?; + terminal.wait_for_screen_compact_contains("TRANSCRIPT", TRANSCRIPT_TIMEOUT)?; + terminal.write_input(b"\x1b[H")?; + terminal.wait_for_screen_compact_contains("SEED_000", TRANSCRIPT_TIMEOUT)?; + let top = terminal.screen_contents(); + ensure!( + !top.contains("NESTED_REVIEW_PROMPT"), + "nested review prompt leaked into top transcript page:\n{top}" + ); + + terminal.write_input(b"\x1b[F")?; + terminal.wait_for_screen_compact_contains("SEED_050", TRANSCRIPT_TIMEOUT)?; + let bottom = terminal.screen_contents(); + ensure!( + !bottom.contains("NESTED_REVIEW_PROMPT"), + "nested review prompt leaked into newest transcript page:\n{bottom}" + ); + let mut previous_separator = false; + for line in bottom.lines().filter(|line| !line.trim().is_empty()) { + let separator = line.trim().starts_with("---"); + ensure!( + !(separator && previous_separator), + "completion separators duplicated after cross-page reconciliation:\n{bottom}" + ); + previous_separator = separator; + } + + terminal.write_input(&[0x14])?; + terminal.wait_for_screen_without("TRANSCRIPT", TRANSCRIPT_TIMEOUT)?; + terminal.write_input(&[0x04])?; + terminal.wait_for_exit()?; + ensure!( + terminal.output_contains(b"\x1b[?1049l"), + "alternate screen was not restored after history pagination test" + ); + Ok(()) +} +fn required_path(name: &str) -> PathBuf { + std::env::var_os(name) + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("missing {name}")) +} diff --git a/lime-rs/crates/tui/tests/suite/mod.rs b/lime-rs/crates/tui/tests/suite/mod.rs index 9a5846dbb..313136165 100644 --- a/lime-rs/crates/tui/tests/suite/mod.rs +++ b/lime-rs/crates/tui/tests/suite/mod.rs @@ -2,6 +2,8 @@ #[cfg(unix)] mod focus_palette; #[cfg(unix)] +mod history_pagination; +#[cfg(unix)] mod reconnect; #[cfg(unix)] mod resize_reflow; diff --git a/package.json b/package.json index 6b43b55e0..3dc8d186b 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "workspaces": [ "packages/*" ], - "version": "1.144.1", + "version": "1.145.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", @@ -216,6 +216,7 @@ "smoke:cli-surface-gate-b": "node scripts/app-server/cli-surface-gate-b.mjs", "smoke:cli-npm-gate-b": "node scripts/app-server/cli-npm-gate-b.mjs", "smoke:tui-gate-b": "node scripts/app-server/tui-gate-b.mjs", + "smoke:tui-history-pagination": "node scripts/app-server/tui-history-pagination-fixture.mjs", "smoke:app-server-sidecar-lifecycle": "npm --prefix \"packages/app-server-client\" run build && node scripts/app-server/sidecar-lifecycle-smoke.mjs", "smoke:app-server-external-backend": "npm --prefix \"packages/app-server-client\" run build && node scripts/app-server/external-backend-smoke.mjs", "smoke:app-server-packaged-external-backend-failure": "npm --prefix \"packages/app-server-client\" run build && node scripts/app-server/packaged-external-backend-failure-smoke.mjs", diff --git a/packages/app-server-client/src/generated/protocol-types.ts b/packages/app-server-client/src/generated/protocol-types.ts index c888a83b7..bd93f937c 100644 --- a/packages/app-server-client/src/generated/protocol-types.ts +++ b/packages/app-server-client/src/generated/protocol-types.ts @@ -10625,6 +10625,7 @@ export interface ToolRequestUserInputOption { export interface ToolRequestUserInputParams { autoResolutionMs?: number | null; + isBlocking: boolean; itemId: string; questions: ToolRequestUserInputQuestion[]; threadId: string; diff --git a/packages/cli/package.json b/packages/cli/package.json index effaf30ea..9f0daa88b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@limecloud/lime", - "version": "1.144.1", + "version": "1.145.0", "packageManager": "pnpm@9.15.9", "description": "Lime CLI is an agent that runs in your terminal.", "bin": { diff --git a/scripts/app-server/tui-history-pagination-fixture.mjs b/scripts/app-server/tui-history-pagination-fixture.mjs new file mode 100644 index 000000000..3c6058ba9 --- /dev/null +++ b/scripts/app-server/tui-history-pagination-fixture.mjs @@ -0,0 +1,288 @@ +#!/usr/bin/env node + +import { access, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { localAppServerBinaryPath } from "../lib/electron-dev-sidecar.mjs"; +import { buildTerminalGateBinaries } from "./terminal-gate-binaries.mjs"; + +const execFileAsync = promisify(execFile); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const rootDir = path.resolve(__dirname, "../.."); +const clientDistPath = path.join( + rootDir, + "packages", + "app-server-client", + "dist", + "index.js", +); +const cliBinaryName = process.platform === "win32" ? "lime.exe" : "lime"; +const defaultCliBinaryPath = path.join( + rootDir, + "lime-rs", + "target", + "debug", + cliBinaryName, +); + +const { + PROTOCOL_VERSION, + connectAppServerSidecar, + resolveSidecarBinaryPath, + sidecarArgs, + stdioSidecar, +} = await import(pathToFileURL(clientDistPath).href); + +async function main() { + await buildTerminalGateBinaries({ env: process.env, repoRoot: rootDir }); + const cliBinaryPath = path.resolve( + process.env.LIME_CLI_BIN || defaultCliBinaryPath, + ); + const appServerBinaryPath = path.resolve( + process.env.APP_SERVER_BIN || + localAppServerBinaryPath({ repoRoot: rootDir }), + ); + await Promise.all([ + assertExists(cliBinaryPath, "lime"), + assertExists(appServerBinaryPath, "app-server"), + ]); + const runtimeEnv = await resolveRuntimeEnvironment(); + + const tempDir = await mkdtemp(path.join(tmpdir(), "tui-history-pagination-")); + const dataDir = path.join(tempDir, "data"); + const appDataDir = path.join(tempDir, "app-data"); + const backendPath = path.join(tempDir, "history-backend.mjs"); + const ledgerPath = path.join(tempDir, "history-backend.jsonl"); + try { + await writeFile(backendPath, backendSource()); + const config = { + ...stdioSidecar(appServerBinaryPath, undefined, dataDir), + backendMode: "external", + backendCommand: process.execPath, + backendArgs: [backendPath, ledgerPath], + backendTimeoutMs: 5_000, + }; + const connected = await connectAppServerSidecar( + config, + { + clientInfo: { name: "tui-history-pagination-fixture", version: "1" }, + }, + { + initializeTimeoutMs: 10_000, + expectedProtocolVersion: PROTOCOL_VERSION, + args: [...sidecarArgs(config), "--app-data-dir", appDataDir], + env: runtimeEnv, + }, + ); + + const connection = connected.connection; + const started = await connection.startSession({ + cwd: tempDir, + runtimeWorkspaceRoots: [tempDir], + model: "fixture-model", + modelProvider: "fixture-provider", + historyMode: "paginated", + }); + const threadId = started.result.thread.id; + + // Each completed turn contributes a user and assistant item. Keep the seed deterministic and + // above the TUI's 100-item page size so the resume path must fetch an older page. + for (let index = 0; index < 51; index += 1) { + const result = await connection.startTurn({ + threadId, + input: [{ type: "text", text: `SEED_${String(index).padStart(3, "0")}` }], + }); + await drainTurnCompletion(connection, result, result.result.turn.id); + } + + const review = await connection.startReview({ + threadId, + delivery: "inline", + target: { type: "commit", sha: "fixture-sha", title: "fixture review" }, + }); + await drainTurnCompletion(connection, review, review.result.turn.id); + + const blocked = connection.startTurn({ + threadId, + input: [{ type: "text", text: "NESTED_REVIEW_PROMPT" }], + }); + const blockedTurnId = await waitForInProgressTurn(connection, threadId); + await connection.steerTurn({ + threadId, + expectedTurnId: blockedTurnId, + clientUserMessageId: "nested-review-duplicate", + input: [{ type: "text", text: "NESTED_REVIEW_PROMPT" }], + }); + // Fork while the tail turn is still in progress. The runtime snapshots that turn as + // Interrupted with no completion timestamp, matching Codex's nested-review replay shape + // while preserving the canonical duplicate UserMessage items. + const forked = await connection.forkThread({ + threadId, + excludeTurns: true, + }); + const resumeThreadId = forked.result.thread.id; + + // Stop the seed sidecar before launching the independent TUI sidecar. + connected.sidecar.child.kill("SIGKILL"); + await connected.sidecar.waitForExit(5_000); + await connected.sidecar.close().catch(() => undefined); + await blocked.catch(() => undefined); + + const metadataPath = path.join(tempDir, "thread.json"); + await writeFile( + metadataPath, + JSON.stringify({ + threadId: resumeThreadId, + tempDir, + dataDir, + appDataDir, + backendPath, + ledgerPath, + }), + ); + await execFileAsync( + process.env.CARGO || "cargo", + [ + "test", + "--locked", + "--manifest-path", + path.join(rootDir, "lime-rs", "Cargo.toml"), + "-p", + "tui", + "--test", + "all", + "suite::history_pagination::older_pagination_reconciles_review_prompts_across_page_boundaries", + "--", + "--exact", + "--nocapture", + ], + { + cwd: rootDir, + env: { + ...process.env, + ...runtimeEnv, + LIME_TEST_TUI_HISTORY_PAGINATION: "1", + LIME_TEST_CLI_BIN: cliBinaryPath, + LIME_TEST_APP_SERVER_BIN: appServerBinaryPath, + LIME_TEST_TERMINAL_BACKEND: backendPath, + LIME_TEST_TERMINAL_LEDGER: ledgerPath, + LIME_TEST_TERMINAL_CWD: tempDir, + LIME_TEST_NODE_BIN: process.execPath, + LIME_TEST_TUI_HISTORY_THREAD_ID: resumeThreadId, + LIME_TEST_TUI_GATE_B: "1", + }, + maxBuffer: 4 * 1024 * 1024, + timeout: 180_000, + windowsHide: true, + }, + ); + console.log( + `[smoke:tui-history-pagination] ok thread=${resumeThreadId} items>100 review=nested-filtered alternate-screen=restored`, + ); + } finally { + if (process.env.LIME_KEEP_TUI_HISTORY_PAGINATION_TMP !== "1") { + await rm(tempDir, { recursive: true, force: true }); + } else { + console.error(`[smoke:tui-history-pagination] kept temp dir ${tempDir}`); + } + } +} + +async function resolveRuntimeEnvironment() { + if (process.platform !== "darwin") { + return {}; + } + const libraryDirs = [path.join(rootDir, "lime-rs", "target", "debug")]; + const prebuiltRoot = path.join( + rootDir, + "lime-rs", + "target", + "sherpa-onnx-prebuilt", + ); + const entries = await readdir(prebuiltRoot, { withFileTypes: true }).catch( + () => [], + ); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const libDir = path.join(prebuiltRoot, entry.name, "lib"); + try { + await access(libDir); + libraryDirs.push(libDir); + } catch { + // Optional prebuilt variants are absent on most developer machines. + } + } + if (process.env.DYLD_LIBRARY_PATH) { + libraryDirs.push(process.env.DYLD_LIBRARY_PATH); + } + return { DYLD_LIBRARY_PATH: libraryDirs.join(path.delimiter) }; +} + +async function drainTurnCompletion(connection, initial, turnId) { + const notifications = initial.notifications || []; + if (notifications.some((entry) => entry.method === "turn/completed" && entry.params?.turn?.id === turnId)) { + return; + } + while (true) { + const notification = await connection.nextNotification(30_000); + if (notification.method === "turn/completed" && notification.params?.turn?.id === turnId) { + return; + } + } +} + +async function waitForInProgressTurn(connection, threadId) { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const read = await connection.readThread({ threadId, includeTurns: true }); + const turn = [...(read.result.thread.turns || [])] + .reverse() + .find((candidate) => candidate.status === "inProgress"); + if (turn?.id) return turn.id; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("blocked turn did not become inProgress"); +} + +async function assertExists(target, label) { + try { + await access(target); + } catch { + throw new Error(`${label} binary not found: ${target}`); + } +} + +function backendSource() { + return `#!/usr/bin/env node +import { appendFileSync, readFileSync } from "node:fs"; +const input = JSON.parse(readFileSync(0, "utf8")); +const ledger = process.argv[2]; +const request = input.request || {}; +const text = JSON.stringify(request.input || []); +const turnId = request.turn?.turnId || null; +const threadId = request.session?.threadId || null; +const blocked = input.kind === "turnStart" && text.includes("NESTED_REVIEW_PROMPT"); +let events = [{ type: "turn.started", payload: {} }]; +if (input.kind === "turnStart" && !blocked) { + const assistantText = text.includes("SEED_") ? text.match(/SEED_\\d{3}/)?.[0] || "seed" : "review complete"; + events.push({ type: "message.delta", payload: { itemId: "assistant-" + turnId, text: assistantText } }); + events.push({ type: "message.completed", payload: { itemId: "assistant-" + turnId, status: "completed", text: assistantText } }); + events.push({ type: "turn.completed", payload: { status: "completed" } }); +} else if (input.kind === "turnCancel") { + events = [{ type: "turn.canceled", payload: { status: "canceled" } }]; +} +appendFileSync(ledger, JSON.stringify({ kind: input.kind, threadId, turnId, blocked, eventTypes: events.map((event) => event.type) }) + "\\n"); +console.log(JSON.stringify({ events })); +if (blocked) { setInterval(() => {}, 1000); await new Promise(() => {}); } +`; +} + +main().catch((error) => { + console.error(`[smoke:tui-history-pagination] failed: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; +});