Release v1.145.0

This commit is contained in:
coso
2026-09-15 15:51:28 +08:00
parent 2a0a30de00
commit 3823e9092d
93 changed files with 11960 additions and 1970 deletions
+26 -8
View File
@@ -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`
+21 -8
View File
@@ -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`
@@ -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<usize>` 与硬编码按键边界,没有引入 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`。
File diff suppressed because it is too large Load Diff
+37 -36
View File
@@ -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",
+1 -1
View File
@@ -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"
@@ -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<ActionRequiredScope>,
pub timeout: Duration,
}
@@ -20,11 +21,13 @@ pub struct RequestUserInputRunRequest {
impl RequestUserInputRunRequest {
pub fn new(
request: RequestUserInputRequest,
is_blocking: bool,
scope: Option<ActionRequiredScope>,
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<u64>,
pub scope: Option<ActionRequiredScope>,
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!(
@@ -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<String>) {
}
}
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([
@@ -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<ActionRequiredState>,
response_handle: RuntimeSessionInputHandle,
item_id: String,
is_blocking: bool,
scope: Option<RuntimeActionRequiredScope>,
event_sender: UnboundedSender<AgentEvent>,
) -> 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()));
@@ -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",
@@ -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",
@@ -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",
@@ -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 {
@@ -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<Vec<ToolRequestUserInputOption>>,
}
#[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<ToolRequestUserInputQuestion>,
pub is_blocking: bool,
/// @deprecated Use `isBlocking` to decide whether the request should block.
#[serde(default)]
pub auto_resolution_ms: Option<u64>,
}
impl<'de> Deserialize<'de> for ToolRequestUserInputParams {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct WireToolRequestUserInputParams {
thread_id: String,
turn_id: String,
item_id: String,
questions: Vec<ToolRequestUserInputQuestion>,
is_blocking: Option<bool>,
auto_resolution_ms: Option<u64>,
}
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 {
@@ -494,6 +494,9 @@ fn user_input_request(event: &AgentEvent) -> Result<Option<UserInputRequest>, 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!(
@@ -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(
@@ -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 { .. }
@@ -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",
));
@@ -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(&[
@@ -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,
+1
View File
@@ -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 = [
+76 -159
View File
@@ -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<String>,
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<crate::clipboard_copy::ClipboardLease>,
@@ -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<Instant>,
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<String> {
@@ -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<Duration> {
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<PathBuf> {
self.composer.take_pending_images()
}
pub(crate) fn restore_pending_images(&mut self, images: Vec<PathBuf>) {
self.composer.restore_pending_images(images);
}
pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> {
self.composer.take_remote_image_urls()
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.composer.set_remote_image_urls(urls);
}
pub(crate) fn set_queued_submissions(&mut self, submissions: Vec<QueuedSubmission>) {
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<McpServerStatus>,
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)]
+127 -27
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("Agent 12"),
"selected agent was clipped: {text}"
);
assert!(text.lines().all(|line| line.chars().count() <= 80));
}
}
@@ -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::<Vec<_>>();
@@ -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::<String>();
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::<String>();
assert!(compact.contains("/"), "cwd prefix missing: {text}");
assert!(
compact.contains("workspace/project"),
"cwd description missing: {text}"
);
}
}
@@ -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)),
}
}
+172 -13
View File
@@ -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<app_server_protocol::protocol::v2::ThreadItem>,
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<usize> {
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<_>>(),
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<_>>(),
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<_>>(),
vec!["older-visible", "visible"]
);
}
}
+51 -4
View File
@@ -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<HyperlinkLine> {
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::<Vec<_>>()
.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 {
+6 -152
View File
@@ -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.
+153
View File
@@ -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)
}
}
}
}
@@ -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<PathBuf> {
self.composer.take_pending_images()
}
pub(crate) fn restore_pending_images(&mut self, images: Vec<PathBuf>) {
self.composer.restore_pending_images(images);
}
pub(crate) fn take_remote_image_urls(&mut self) -> Vec<String> {
self.composer.take_remote_image_urls()
}
pub(crate) fn set_remote_image_urls(&mut self, urls: Vec<String>) {
self.composer.set_remote_image_urls(urls);
}
pub(crate) fn set_queued_submissions(&mut self, submissions: Vec<QueuedSubmission>) {
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,
}
}
}
+41
View File
@@ -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));
}
}
@@ -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,
},
}
@@ -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,
},
};
+106 -2
View File
@@ -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();
@@ -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,
},
}
+8 -27
View File
@@ -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(&params.item);
super::tool_lifecycle::observe_item(self, &params.item);
}
ServerNotification::ItemCompleted(params) => {
self.observe_item(&params.item);
super::tool_lifecycle::observe_item(self, &params.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());
}
}
}
}
@@ -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));
}
}
@@ -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<String>,
pub(super) budget_limited_turn_ids: HashSet<String>,
/// Completion labels already inserted into this thread's visible history.
#[allow(dead_code)]
pub(super) rendered_completion_turn_ids: HashSet<String>,
pub(super) active_turn_started_at: Option<Instant>,
}
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<Duration> {
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<Duration> {
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"));
}
}
+39 -4
View File
@@ -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<Vec<McpServerStatus>> {
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.
@@ -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}");
@@ -138,3 +138,10 @@ fn turn_lookup_returns_chronological_order_for_history_filtering() {
.collect::<Vec<_>>();
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));
}
@@ -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();
@@ -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<FileSearchRequest>,
file_search_requested_query: Option<String>,
dismissed_file_token: Option<DismissedToken>,
skills: Vec<SkillMetadata>,
dismissed_skill_token: Option<DismissedToken>,
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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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<usize>, 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)
}
@@ -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
);
}
}
@@ -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<usize>, Range<usize>, 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::<usize>();
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<usize>,
prefix: char,
allow_empty: bool,
) -> Option<(Range<usize>, 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<usize>, 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<usize>, 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;
@@ -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
);
}
@@ -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),
])
})
@@ -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<String>,
pub(super) dismissed_file_token: Option<DismissedToken>,
pub(super) dismissed_skill_token: Option<DismissedToken>,
pub(super) file_search_requested_query: Option<String>,
}
impl ActivePopup {
@@ -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());
@@ -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() {
@@ -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 {
@@ -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<Self> {
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<SlashCommand> {
self.matches()
}
pub(crate) fn selected(&self) -> Option<SlashCommand> {
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> {
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::<Vec<_>>();
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::<String>();
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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let compact = text
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>();
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::<String>();
row.contains(&format!("› /{}", selected.command()))
})
.count();
assert_eq!(rows_with_marker, 1);
assert!(popup.commands().len() > MAX_VISIBLE_ROWS);
}
}
+354 -28
View File
@@ -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<Line<'static>> {
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<Line<'static>>,
vim_indicator: Option<Span<'static>>,
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::<Span<'static>>::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<Span<'static>>, indicator: Option<Span<'static>>) {
@@ -91,9 +172,131 @@ fn append_vim_indicator(spans: &mut Vec<Span<'static>>, indicator: Option<Span<'
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SummaryHintKind {
None,
DraftReady,
QueueMessage,
}
#[derive(Debug)]
pub(crate) enum SummaryLeft {
Default,
Custom(Line<'static>),
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<Line<'static>>,
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<u16> {
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::<String>();
let expected = locale
.shortcuts_hint()
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>();
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();
@@ -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<Line<'static>> {
lines_with_locale_inner(overlay, locale, None)
}
pub(super) fn lines_with_locale_with_width(
overlay: &McpServerElicitationOverlay,
locale: Locale,
width: usize,
) -> Vec<Line<'static>> {
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::<Vec<_>>()
.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::<Vec<_>>()
.into_iter()
})
.collect()
}
fn lines_with_locale_inner(
overlay: &McpServerElicitationOverlay,
locale: Locale,
width: Option<usize>,
) -> Vec<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
let Line {
style,
alignment,
spans,
} = line;
let mut rows = vec![Vec::<Span<'static>>::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::<Vec<_>>();
Line::from(spans).style(style)
}
fn footer_control_lines(locale: Locale, select: bool, width: Option<usize>) -> Vec<Line<'static>> {
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::<Vec<_>>();
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::<String>::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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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);
}
}
+46 -3
View File
@@ -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<AppServerResponse> {
match self {
Self::UserInput(request) => request.pre_draw_tick(now),
Self::Approval(_) | Self::McpElicitation(_) => None,
}
}
fn next_frame_delay(&self, now: Instant) -> Option<Duration> {
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<String> {
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<AppServerResponse> {
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<Duration> {
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,
},
})
@@ -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<Line<'static>> {
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<Line<'static>> {
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::<Vec<_>>();
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::<Vec<_>>();
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<UserInput>) -> 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::<Vec<_>>()
.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::<String>()
.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::<Vec<_>>().join("\n");
assert!(text.contains(queued), "{locale:?}: {text}");
assert!(text.contains(image), "{locale:?}: {text}");
}
}
}
+152 -18
View File
@@ -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<Line<'static>> {
fn lines_with_locale(
pane: &BottomPane,
locale: Locale,
width: usize,
now: Instant,
) -> Vec<Line<'static>> {
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<Lin
details
.into_iter()
.filter(|detail| !detail.is_empty())
.map(|detail| Line::styled(detail, Style::default().fg(Color::DarkGray))),
.map(|detail| Line::styled(detail, muted_style())),
);
let labels = approval.option_labels();
let (start, end) = visible_item_window(approval.selected, labels.len(), MAX_POPUP_ROWS);
lines.extend(
approval
.option_labels()
labels
.into_iter()
.enumerate()
.skip(start)
.take(end.saturating_sub(start))
.map(|(index, label)| {
option_line(index == approval.selected, locale.approval_option(&label))
option_line(
index == approval.selected,
format!("{}. {}", index + 1, locale.approval_option(&label)),
)
}),
);
lines
if width == usize::MAX {
lines
} else {
lines
.into_iter()
.flat_map(|line| {
word_wrap_line(&line, RtOptions::new(width.max(1)))
.into_iter()
.map(|wrapped| {
let style = wrapped.style;
Line::from(
wrapped
.spans
.into_iter()
.map(|span| {
ratatui::text::Span::styled(
span.content.into_owned(),
span.style,
)
})
.collect::<Vec<_>>(),
)
.style(style)
})
.collect::<Vec<_>>()
})
.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<Lin
}
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(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::<Vec<_>>();
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")
);
}
}
@@ -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<String, ToolRequestUserInputAnswer>,
/// Stores one notes draft per question so navigation does not lose input.
question_drafts: Vec<String>,
/// Stores option selection per question for reversible focus changes.
question_selections: Vec<usize>,
/// Stores the Options/Notes focus per question.
question_editing: Vec<bool>,
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<Duration> {
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<AppServerResponse> {
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<String> {
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<AppServerResponse> {
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<String>) -> Option<AppServerResponse> {
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:?}");
}
}
}
}
@@ -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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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::<Vec<_>>();
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<Line<'static>>,
width: usize,
editing: bool,
) -> Vec<Line<'static>> {
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<Line<'static>> {
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()
};
@@ -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<Span<'static>>,
pub(crate) description: Option<String>,
pub(crate) disabled_reason: Option<String>,
}
impl SelectionRow {
pub(crate) fn new(
name: impl Into<String>,
description: Option<String>,
name_prefix_spans: Vec<Span<'static>>,
) -> 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<String> {
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<String> {
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<Span<'static>> {
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<Line<'static>> {
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::<Vec<_>>();
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<Line<'static>> {
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"));
}
}
+6 -218
View File
@@ -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<Self> {
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<SlashCommand> {
self.matches()
}
pub(crate) fn selected(&self) -> Option<SlashCommand> {
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> {
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::<Vec<_>>();
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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
let compact = text
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>();
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};
+78 -46
View File
@@ -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<EntryStatus>, 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
+280
View File
@@ -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<Line<'static>> {
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>()
.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<McpServerConnectionStatus>,
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>();
assert_eq!(text[4], "未配置 MCP 服务器。");
}
}
+1 -1
View File
@@ -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
+227 -6
View File
@@ -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<String>,
pub(crate) permissions: Option<String>,
pub(crate) directory: std::path::PathBuf,
pub(crate) locale: Locale,
}
impl SessionHeaderHistoryCell {
pub(crate) fn new(
model: impl Into<String>,
reasoning_effort: Option<String>,
permissions: Option<String>,
directory: std::path::PathBuf,
version: impl Into<String>,
locale: Locale,
) -> Self {
Self {
version: version.into(),
model: model.into(),
reasoning_effort,
permissions,
directory,
locale,
}
}
fn format_directory(&self, max_width: Option<usize>) -> 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<Line<'static>> {
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<Line<'static>> {
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<Line<'static>> {
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<usize> {
(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<Line<'static>>) -> Vec<Line<'static>> {
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::<String>();
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::<String>();
assert!(text.contains("permissions"));
assert!(text.contains(":danger-full-access"));
}
}
+5
View File
@@ -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;
+409
View File
@@ -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"
);
}
}
+4 -3
View File
@@ -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::<Vec<_>>()
.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(),
)));
}
}
+155 -34
View File
@@ -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::<Vec<_>>();
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::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
text.contains("model-12"),
"selected model was clipped: {text}"
);
assert!(text.lines().all(|line| line.chars().count() <= 80));
}
}
+142 -2
View File
@@ -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<usize>,
max_scroll: Cell<usize>,
pinned_to_bottom: Cell<bool>,
older_history_available: Cell<bool>,
/// 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::<Vec<_>>();
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());
}
}
+8 -316
View File
@@ -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<Line<'static>> {
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<Line<'static>> {
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::<Vec<_>>();
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::<Vec<_>>();
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<UserInput>) -> 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::<Vec<_>>()
.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::<String>()
.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::<Vec<_>>().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,
};
+192 -34
View File
@@ -16,6 +16,23 @@ use crate::history_filter::{
};
use crate::multi_agents;
fn latest_summary_line(text: &str) -> Option<String> {
text.lines().rev().find_map(|line| {
let line = line.trim();
if line.is_empty() || line.starts_with("<!--") {
return None;
}
let line = line.trim_start_matches('#').trim();
let line = if let Some(stripped) = line.strip_prefix("**") {
let (bold, trailing) = stripped.split_once("**")?;
format!("{bold}{trailing}")
} else {
line.to_string()
};
(!line.is_empty()).then_some(line)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EntryKind {
User,
@@ -83,7 +100,12 @@ pub(crate) struct ConversationProjection {
entries: Vec<TranscriptEntry>,
completion_boundaries: Vec<CompletionBoundary>,
active_turn_id: Option<String>,
/// Turn ids whose terminal notification or hydrated canonical record has already settled.
/// Late stream notifications for these turns must not create new provisional entries.
closed_turn_ids: HashSet<String>,
status: String,
/// Latest usable reasoning summary while the active turn is still running.
reasoning_status: Option<String>,
review_mode: bool,
active_hooks: Vec<ActiveHook>,
/// Explicit assistant phases keyed by canonical item id. Legacy items
@@ -144,10 +166,46 @@ impl ConversationProjection {
}
pub(crate) fn status(&self) -> &str {
self.hook_status().unwrap_or(&self.status)
if let Some(hook) = self.hook_status_message() {
return hook;
}
if self.active_turn_id.is_some() {
if let Some(reasoning) = self.reasoning_status.as_deref() {
return reasoning;
}
}
&self.status
}
/// Returns the active hook summary for the status indicator, if any.
///
/// Hook state remains owned by the canonical projection; the view only consumes this
/// display-ready string and never inspects individual protocol notifications.
pub(crate) fn hook_status_message(&self) -> Option<&str> {
if self.active_hooks.is_empty() {
return None;
}
let first = self.active_hooks[0]
.status_message
.as_deref()
.map(str::trim)
.filter(|message| !message.is_empty());
if self.active_hooks.len() == 1 {
return first.or(Some("running hook"));
}
if first.is_some()
&& self
.active_hooks
.iter()
.all(|hook| hook.status_message.as_deref().map(str::trim) == first)
{
return first;
}
Some("running hooks")
}
pub(crate) fn set_status(&mut self, status: impl Into<String>) {
self.reasoning_status = None;
self.status = status.into();
}
@@ -156,6 +214,7 @@ impl ConversationProjection {
}
pub(crate) fn start_turn(&mut self, turn_id: String) {
self.reasoning_status = None;
self.active_turn_id = Some(turn_id);
self.status = "running".to_string();
}
@@ -249,13 +308,21 @@ impl ConversationProjection {
self.entries.clear();
self.completion_boundaries.clear();
self.active_turn_id = None;
self.closed_turn_ids.clear();
self.status = "ready".to_string();
self.reasoning_status = None;
self.review_mode = false;
self.active_hooks.clear();
self.assistant_phases.clear();
let hidden_user_messages = hidden_user_message_ids(&thread.turns);
for turn in thread.turns {
if matches!(
turn.status,
TurnStatus::Completed | TurnStatus::Failed | TurnStatus::Interrupted
) {
self.closed_turn_ids.insert(turn.id.clone());
}
if turn.status == TurnStatus::InProgress {
self.active_turn_id = Some(turn.id.clone());
self.status = "running".to_string();
@@ -271,6 +338,7 @@ impl ConversationProjection {
continue;
}
if let Some(entry) = project_item(&item, false) {
self.remember_reasoning_status(turn.id.as_str(), &entry);
self.replace_entry(entry);
}
}
@@ -295,11 +363,14 @@ impl ConversationProjection {
pub(crate) fn apply(&mut self, notification: ServerNotification) {
match notification {
ServerNotification::TurnStarted(params) => {
self.closed_turn_ids.remove(&params.turn.id);
self.reasoning_status = None;
self.active_turn_id = Some(params.turn.id);
self.status = "running".to_string();
}
ServerNotification::TurnCompleted(params) => {
self.active_turn_id = None;
self.reasoning_status = None;
self.active_hooks
.retain(|hook| hook.turn_id.as_deref() != Some(params.turn.id.as_str()));
// The terminal client may miss an item delta or item/completed
@@ -331,15 +402,25 @@ impl ConversationProjection {
}
}
self.settle_running_entries(params.turn.status);
if matches!(
params.turn.status,
TurnStatus::Completed | TurnStatus::Failed | TurnStatus::Interrupted
) {
self.closed_turn_ids.insert(params.turn.id.clone());
}
self.status = turn_status(params.turn.status).to_string();
}
ServerNotification::ItemStarted(params) => {
if self.closed_turn_ids.contains(&params.turn_id) {
return;
}
if self.should_hide_realtime_item(&params.item) {
return;
}
if let Some(entry) =
project_item_with_lifecycle(&params.item, true, WebSearchLifecycle::Started)
{
self.remember_reasoning_status(&params.turn_id, &entry);
self.record_assistant_phase(&params.item);
self.replace_entry(entry);
}
@@ -351,26 +432,58 @@ impl ConversationProjection {
if let Some(entry) =
project_item_with_lifecycle(&params.item, false, WebSearchLifecycle::Completed)
{
self.remember_reasoning_status(&params.turn_id, &entry);
self.record_assistant_phase(&params.item);
self.replace_entry(entry);
}
}
ServerNotification::AgentMessageDelta(params) => {
self.append_delta(params.item_id, EntryKind::Assistant, params.delta);
self.append_delta(
params.turn_id,
params.item_id,
EntryKind::Assistant,
params.delta,
);
}
ServerNotification::ReasoningSummaryTextDelta(params) => {
self.append_delta(params.item_id, EntryKind::Reasoning, params.delta);
self.append_delta(
params.turn_id,
params.item_id,
EntryKind::Reasoning,
params.delta,
);
}
ServerNotification::ReasoningSummaryPartAdded(params) => {
self.append_reasoning_section_break(params.turn_id, params.item_id);
}
ServerNotification::ReasoningTextDelta(params) => {
self.append_delta(params.item_id, EntryKind::Reasoning, params.delta);
self.append_delta(
params.turn_id,
params.item_id,
EntryKind::Reasoning,
params.delta,
);
}
ServerNotification::PlanDelta(params) => {
self.append_delta(params.item_id, EntryKind::Plan, params.delta);
self.append_delta(
params.turn_id,
params.item_id,
EntryKind::Plan,
params.delta,
);
}
ServerNotification::CommandExecutionOutputDelta(params) => {
self.append_delta(params.item_id, EntryKind::Command, params.delta);
self.append_delta(
params.turn_id,
params.item_id,
EntryKind::Command,
params.delta,
);
}
ServerNotification::FileChangePatchUpdated(params) => {
if self.closed_turn_ids.contains(&params.turn_id) {
return;
}
self.replace_entry(TranscriptEntry {
id: params.item_id,
kind: EntryKind::Patch,
@@ -381,6 +494,9 @@ impl ConversationProjection {
});
}
ServerNotification::TurnDiffUpdated(params) => {
if self.closed_turn_ids.contains(&params.turn_id) {
return;
}
self.replace_entry(TranscriptEntry {
id: format!("turn-{}-diff", params.turn_id),
kind: EntryKind::Patch,
@@ -391,6 +507,9 @@ impl ConversationProjection {
});
}
ServerNotification::TurnPlanUpdated(params) => {
if self.closed_turn_ids.contains(&params.turn_id) {
return;
}
let text = params
.plan
.iter()
@@ -410,6 +529,7 @@ impl ConversationProjection {
self.push_system(format!("warning: {}", params.message));
}
ServerNotification::Error(params) => {
self.reasoning_status = None;
self.status = if params.will_retry {
"retrying".to_string()
} else {
@@ -472,36 +592,29 @@ impl ConversationProjection {
});
}
fn hook_status(&self) -> Option<&str> {
if self.active_hooks.is_empty() {
return None;
}
let first = self.active_hooks[0]
.status_message
.as_deref()
.map(str::trim)
.filter(|message| !message.is_empty());
if self.active_hooks.len() == 1 {
return first.or(Some("running hook"));
}
if first.is_some()
&& self
.active_hooks
.iter()
.all(|hook| hook.status_message.as_deref().map(str::trim) == first)
{
return first;
}
Some("running hooks")
}
fn append_delta(&mut self, id: String, kind: EntryKind, delta: String) {
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
entry.text.push_str(&delta);
entry.streaming = true;
fn append_delta(&mut self, turn_id: String, id: String, kind: EntryKind, delta: String) {
if self.closed_turn_ids.contains(&turn_id) {
return;
}
self.entries.push(TranscriptEntry {
let active_turn = self.active_turn_id.as_deref() == Some(turn_id.as_str());
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
// Once an item has been replaced by its canonical completion (or a terminal turn
// settled the provisional stream), late transport deltas must not reopen it. Codex
// flushes the active stream before accepting terminal history for the same reason.
if !entry.streaming || entry.kind != kind {
return;
}
entry.text.push_str(&delta);
entry.streaming = true;
let reasoning_status = (active_turn && kind == EntryKind::Reasoning)
.then(|| latest_summary_line(&entry.text))
.flatten();
if let Some(reasoning_status) = reasoning_status {
self.reasoning_status = Some(reasoning_status);
}
return;
}
let entry = TranscriptEntry {
id,
kind,
text: delta,
@@ -509,9 +622,51 @@ impl ConversationProjection {
status: (kind == EntryKind::Command || kind == EntryKind::Plan)
.then_some(EntryStatus::Running),
summary: Vec::new(),
};
if active_turn {
self.remember_reasoning_status(&turn_id, &entry);
}
self.entries.push(entry);
}
/// Preserve the boundary between streamed reasoning summary parts.
///
/// The following item completion remains authoritative and replaces this provisional entry
/// with the canonical summary. A completed historical reasoning item is therefore left
/// untouched when a late notification arrives after reconnect.
fn append_reasoning_section_break(&mut self, turn_id: String, id: String) {
if self.closed_turn_ids.contains(&turn_id) {
return;
}
if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) {
if entry.kind != EntryKind::Reasoning || !entry.streaming {
return;
}
if !entry.text.is_empty() && !entry.text.ends_with('\n') {
entry.text.push('\n');
}
return;
}
self.entries.push(TranscriptEntry {
id,
kind: EntryKind::Reasoning,
text: String::new(),
streaming: true,
status: None,
summary: Vec::new(),
});
}
fn remember_reasoning_status(&mut self, turn_id: &str, entry: &TranscriptEntry) {
if self.active_turn_id.as_deref() != Some(turn_id) || entry.kind != EntryKind::Reasoning {
return;
}
if let Some(summary) = latest_summary_line(&entry.text) {
self.reasoning_status = Some(summary);
}
}
fn update_review_mode(&mut self, item: &ThreadItem) {
match item {
ThreadItem::EnteredReviewMode { .. } => self.review_mode = true,
@@ -622,6 +777,9 @@ impl ConversationProjection {
if entry.status == Some(EntryStatus::Running) {
entry.status = Some(entry_status);
}
// Assistant and reasoning entries intentionally have no running status, but they
// still carry a provisional stream flag. A terminal turn closes those tails too.
entry.streaming = false;
}
}
}
+314 -1
View File
@@ -7,7 +7,8 @@ use app_server_protocol::protocol::v2::{
HookCompletedNotification, HookEventName, HookExecutionMode, HookHandlerType, HookOutputEntry,
HookOutputEntryKind, HookRunStatus, HookRunSummary, HookScope, HookSource,
HookStartedNotification, ImageGenerationItem, ItemCompletedNotification,
ItemStartedNotification, McpToolCallError, McpToolCallResult, PatchChangeKind, SessionSource,
ItemStartedNotification, McpToolCallError, McpToolCallResult, PatchChangeKind,
ReasoningSummaryPartAddedNotification, ReasoningSummaryTextDeltaNotification, SessionSource,
SleepItem, Thread, ThreadActiveFlag, ThreadItem, ThreadStatus, Turn, TurnCompletedNotification,
TurnDiffUpdatedNotification, TurnItemsView, TurnPlanStep, TurnPlanStepStatus,
TurnPlanUpdatedNotification, WebSearchItem,
@@ -452,6 +453,31 @@ fn completed_agent_item_replaces_streaming_delta() {
assert_eq!(projection.final_answer(), "你好。");
}
#[test]
fn late_agent_delta_does_not_reopen_completed_item() {
let mut projection = ConversationProjection::default();
projection.apply(ServerNotification::ItemCompleted(
ItemCompletedNotification {
item: agent_message("item-1", "canonical", None),
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
completed_at_ms: 1,
},
));
projection.apply(ServerNotification::AgentMessageDelta(
AgentMessageDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "item-1".to_string(),
delta: " late".to_string(),
},
));
assert_eq!(projection.entries()[0].text, "canonical");
assert!(!projection.entries()[0].streaming);
}
#[test]
fn commentary_agent_messages_are_visible_but_not_final_answers() {
let mut projection = ConversationProjection::default();
@@ -547,6 +573,133 @@ fn streamed_agent_message_phase_is_repaired_by_item_completion() {
assert_eq!(projection.final_answer(), "公开回答");
}
#[test]
fn reasoning_summary_parts_keep_streamed_section_boundaries() {
let mut projection = ConversationProjection::default();
projection.apply(ServerNotification::ReasoningSummaryTextDelta(
ReasoningSummaryTextDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "reasoning-1".to_string(),
delta: "检查输入".to_string(),
summary_index: 0,
},
));
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: "准备回答".to_string(),
summary_index: 1,
},
));
assert_eq!(projection.entries()[0].text, "检查输入\n准备回答");
assert!(projection.entries()[0].streaming);
}
#[test]
fn reasoning_summary_updates_running_status_with_latest_usable_line() {
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 tests**".to_string(),
summary_index: 0,
},
));
assert_eq!(projection.status(), "Checking tests");
projection.apply(ServerNotification::ReasoningSummaryTextDelta(
ReasoningSummaryTextDeltaNotification {
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
item_id: "reasoning-1".to_string(),
delta: "\n<!-- progress -->\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();
+580 -110
View File
@@ -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::<Vec<_>>();
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<Line<'static>> {
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::<Vec<_>>();
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::<Vec<_>>();
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);
+29 -2
View File
@@ -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,
+33 -5
View File
@@ -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<Vec<u8>>,
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<Vec<u8>>,
output: &mut String,
+1
View File
@@ -49,6 +49,7 @@ pub(crate) fn parse_settings_command(prompt: &str) -> Option<Result<SettingsComm
| SlashCommand::Agents
| SlashCommand::MultiAgents
| SlashCommand::Resume
| SlashCommand::Mcp
| SlashCommand::Vim
| SlashCommand::Pwd => None,
}
+5 -1
View File
@@ -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",
]
);
+2 -25
View File
@@ -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 {
@@ -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<String>,
details_max_lines: usize,
inline_message: Option<String>,
hook_status_message: Option<String>,
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<String>) {
self.header = header.into();
}
pub(crate) fn update_details(
&mut self,
details: Option<String>,
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<String>) {
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<String>) {
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<String>) {
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::<Span<'static>>::new()))
}
fn lines(&self, width: u16) -> Vec<Line<'static>> {
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<Line<'static>> {
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::<String>();
let truncated = truncate_line_to_width(Line::from(content), max_base_width);
let text = truncated
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
*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));
}
}
@@ -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<Instant>,
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;
@@ -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
);
}
+256
View File
@@ -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),
);
}
}
+20 -8
View File
@@ -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,
);
}
}
+216
View File
@@ -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<Option<Vec<HyperlinkLine>>>,
previous_width: Cell<u16>,
previous_offset: Cell<usize>,
previous_requested_scroll: Cell<Option<usize>>,
}
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<usize> {
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<usize> {
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]);
}
}
+474 -84
View File
@@ -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<std::time::Duration>,
) -> 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<String> {
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<String> {
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::<Vec<_>>();
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<TestBackend>) -> 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::<String>();
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::<String>();
assert!(
compact.contains('›'),
"missing composer for {locale:?} at {width}: {text}"
);
assert!(
compact.contains(
&locale
.working_label()
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>()
),
"missing active status for {locale:?} at {width}: {text}"
);
assert!(
compact.contains(
&locale
.status("queued")
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>()
),
"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::<String>();
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::<String>();
assert!(compact.contains(model_label), "{locale:?}: {text}");
let compact_status = status_label
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>();
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::<String>();
assert!(
approval_compact.contains(
&locale
.approval_title("command")
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>(),
),
"approval title for {locale:?} at {width}: {approval_text}"
);
assert!(
approval_compact.contains(
&locale
.approval_controls()
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>(),
),
"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::<String>();
assert!(
question_compact.contains(
&locale
.request_submit_hint()
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>(),
),
"submit control for {locale:?} at {width}: {question_text}"
);
assert!(
question_compact.contains(
&locale
.request_cancel_hint()
.chars()
.filter(|character| !character.is_whitespace())
.collect::<String>(),
),
"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,
},
})
@@ -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> {
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> {
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<Self> {
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)
@@ -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}"))
}
+2
View File
@@ -2,6 +2,8 @@
#[cfg(unix)]
mod focus_palette;
#[cfg(unix)]
mod history_pagination;
#[cfg(unix)]
mod reconnect;
#[cfg(unix)]
mod resize_reflow;
+2 -1
View File
@@ -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",
@@ -10625,6 +10625,7 @@ export interface ToolRequestUserInputOption {
export interface ToolRequestUserInputParams {
autoResolutionMs?: number | null;
isBlocking: boolean;
itemId: string;
questions: ToolRequestUserInputQuestion[];
threadId: string;
+1 -1
View File
@@ -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": {
@@ -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;
});