mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-10-16 07:16:36 +08:00
Release v1.145.0
This commit is contained in:
+26
-8
@@ -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
@@ -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
Generated
+37
-36
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::borrow::Cow;
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Instant;
|
||||
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest, ThreadItem};
|
||||
use app_server_protocol::protocol::v2::{ServerNotification, ServerRequest};
|
||||
|
||||
use super::app_server_event_targets::{
|
||||
server_notification_thread_target, ServerNotificationThreadTarget,
|
||||
@@ -216,9 +216,11 @@ impl App {
|
||||
let previous_turn_id = self.projection.active_turn_id().map(str::to_owned);
|
||||
self.projection.apply(notification);
|
||||
let active_turn_id = self.projection.active_turn_id();
|
||||
if active_turn_id != previous_turn_id.as_deref() {
|
||||
self.active_turn_started_at = active_turn_id.map(|_| Instant::now());
|
||||
}
|
||||
self.turn_lifecycle.sync_projection_turn(
|
||||
previous_turn_id.as_deref(),
|
||||
active_turn_id,
|
||||
Instant::now(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn note_outbound_response(
|
||||
@@ -265,33 +267,12 @@ impl App {
|
||||
app_server_protocol::protocol::v2::ThreadStatus::NotLoaded => {}
|
||||
},
|
||||
ServerNotification::ItemStarted(params) => {
|
||||
self.observe_item(¶ms.item);
|
||||
super::tool_lifecycle::observe_item(self, ¶ms.item);
|
||||
}
|
||||
ServerNotification::ItemCompleted(params) => {
|
||||
self.observe_item(¶ms.item);
|
||||
super::tool_lifecycle::observe_item(self, ¶ms.item);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn observe_item(&mut self, item: &ThreadItem) {
|
||||
if let Some(activity) = crate::multi_agents::sub_agent_activity_display(item) {
|
||||
// Activity is emitted on the parent stream before a child thread can be resumed
|
||||
// independently. Treat that observation as authoritative for direct-input policy.
|
||||
self.agent_navigation
|
||||
.mark_parent_owned(activity.thread_id.clone());
|
||||
self.agent_navigation.record_sub_agent_activity(activity);
|
||||
return;
|
||||
}
|
||||
if let ThreadItem::CollabAgentToolCall {
|
||||
tool: app_server_protocol::protocol::v2::CollabAgentTool::SpawnAgent,
|
||||
receiver_thread_ids,
|
||||
..
|
||||
} = item
|
||||
{
|
||||
for thread_id in receiver_thread_ids {
|
||||
self.agent_navigation.mark_parent_owned(thread_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 服务器。");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(¶ms.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(¶ms.turn_id) {
|
||||
return;
|
||||
}
|
||||
if self.should_hide_realtime_item(¶ms.item) {
|
||||
return;
|
||||
}
|
||||
if let Some(entry) =
|
||||
project_item_with_lifecycle(¶ms.item, true, WebSearchLifecycle::Started)
|
||||
{
|
||||
self.remember_reasoning_status(¶ms.turn_id, &entry);
|
||||
self.record_assistant_phase(¶ms.item);
|
||||
self.replace_entry(entry);
|
||||
}
|
||||
@@ -351,26 +432,58 @@ impl ConversationProjection {
|
||||
if let Some(entry) =
|
||||
project_item_with_lifecycle(¶ms.item, false, WebSearchLifecycle::Completed)
|
||||
{
|
||||
self.remember_reasoning_status(¶ms.turn_id, &entry);
|
||||
self.record_assistant_phase(¶ms.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(¶ms.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(¶ms.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(¶ms.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,6 +2,8 @@
|
||||
#[cfg(unix)]
|
||||
mod focus_palette;
|
||||
#[cfg(unix)]
|
||||
mod history_pagination;
|
||||
#[cfg(unix)]
|
||||
mod reconnect;
|
||||
#[cfg(unix)]
|
||||
mod resize_reflow;
|
||||
|
||||
+2
-1
@@ -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,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;
|
||||
});
|
||||
Reference in New Issue
Block a user