From 05f1d146e571c39ba571071077b640f09d287534 Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 17 Jul 2026 22:08:20 +0800 Subject: [PATCH] refactor(cli): harden TUI input and release smoke tests --- .github/workflows/ci.yml | 4 + .github/workflows/cli-package-manual.yml | 24 ++ .github/workflows/cli-package.yml | 24 ++ docs/architecture/cli-product-line-design.md | 25 +- docs/plans/core-decomposition-plan.md | 8 +- src/apps/cli/Cargo.toml | 1 + src/apps/cli/src/modes/chat.rs | 161 ++------- src/apps/cli/src/ui/chat/input.rs | 5 + src/apps/cli/src/ui/input.rs | 348 +++++++++++++++++++ src/apps/cli/src/ui/mod.rs | 1 + src/apps/cli/src/ui/startup.rs | 88 +---- src/apps/cli/src/ui/text_input.rs | 49 ++- src/apps/cli/tests/tui_terminal_process.rs | 197 +++++++++++ 13 files changed, 718 insertions(+), 217 deletions(-) create mode 100644 src/apps/cli/src/ui/input.rs create mode 100644 src/apps/cli/tests/tui_terminal_process.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23795ca8a8..962649f855 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -136,6 +136,10 @@ jobs: - name: Check compilation run: cargo check --locked --workspace + - name: Run Windows ConPTY CLI smoke test + if: runner.os == 'Windows' + run: cargo test --locked -p bitfun-cli --test tui_terminal_process + - name: Run core and desktop Rust tests run: cargo test --locked -p bitfun-core -p bitfun-desktop diff --git a/.github/workflows/cli-package-manual.yml b/.github/workflows/cli-package-manual.yml index cd5bf784c9..e083bd59b5 100644 --- a/.github/workflows/cli-package-manual.yml +++ b/.github/workflows/cli-package-manual.yml @@ -200,6 +200,30 @@ jobs: echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" + - name: Smoke test packaged archive + shell: bash + env: + ARCHIVE: ${{ steps.stage.outputs.archive }} + CHECKSUM: ${{ steps.stage.outputs.checksum }} + run: | + set -euo pipefail + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$CHECKSUM" + else + shasum -a 256 -c "$CHECKSUM" + fi + + EXTRACT_DIR="$(mktemp -d)" + trap 'rm -rf "$EXTRACT_DIR"' EXIT + tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" + + shopt -s nullglob + BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) + [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] + "${BIN_CANDIDATES[0]}" --version + "${BIN_CANDIDATES[0]}" --help > /dev/null + - name: Upload artifact uses: actions/upload-artifact@v6 with: diff --git a/.github/workflows/cli-package.yml b/.github/workflows/cli-package.yml index 33ea37280f..e10ff55e39 100644 --- a/.github/workflows/cli-package.yml +++ b/.github/workflows/cli-package.yml @@ -186,6 +186,30 @@ jobs: echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "checksum=${ARCHIVE}.sha256" >> "$GITHUB_OUTPUT" + - name: Smoke test packaged archive + shell: bash + env: + ARCHIVE: ${{ steps.stage.outputs.archive }} + CHECKSUM: ${{ steps.stage.outputs.checksum }} + run: | + set -euo pipefail + + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c "$CHECKSUM" + else + shasum -a 256 -c "$CHECKSUM" + fi + + EXTRACT_DIR="$(mktemp -d)" + trap 'rm -rf "$EXTRACT_DIR"' EXIT + tar -xzf "$ARCHIVE" -C "$EXTRACT_DIR" + + shopt -s nullglob + BIN_CANDIDATES=("$EXTRACT_DIR"/*/bitfun-cli) + [[ "${#BIN_CANDIDATES[@]}" -eq 1 ]] + "${BIN_CANDIDATES[0]}" --version + "${BIN_CANDIDATES[0]}" --help > /dev/null + - name: Upload artifact uses: actions/upload-artifact@v6 with: diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 1763d985b1..d24cd58c2a 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -106,7 +106,12 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 同路径恢复仍在使用的绑定;已加载会话只校验身份,不通过完整 restore 重置活动状态。删除路径不能通过 相对路径、绝对路径或分隔符越出 sessions 根目录。 - TUI 终端句柄由恢复守卫持有;初始化中途失败、正常返回、错误返回或 panic 展开都会尽力退出 alternate screen、 - 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 故障注入仍需独立验收。 + 关闭输入捕获、关闭 raw mode 并显示光标。真实 PTY/ConPTY 启动页进程冒烟测试已验证 resize 后仍可交互、 + 多行输入、空闲 Ctrl+C 和可观察的终端清理序列;resize 渲染正确性、Chat 活动 turn、初始化失败与异常退出等 + 仍需独立验收。 +- Startup 与 Chat 共用 CLI 私有输入读取器;一次读取同时受 256 个事件和 50ms 限制,跨批次仅延续快速文本尾部, + 短批次普通按键保持原有路由。被识别为粘贴的文本按批次写入输入缓冲,每批只刷新一次命令菜单;粘贴内容中的 + Tab 明确转换为四个空格。 - 初始化按入口分级:交互模式启动 Peer Host 与 MCP,`exec` 只启动 MCP;本地 session 管理和 usage 查询不启动 Peer Host/MCP。该分级不改变 Agentic/Terminal owner,也不等同于管理命令已有独立轻量 Runtime。 - Peer Host 保持既有 HostInvoke / DeviceEvent wire schema 与 Relay 路由,但执行已接入上述调用级上下文: @@ -122,7 +127,8 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop 显示警告。不承诺本次变更范围外的 ACK、重放或重连恢复。 - `doctor` 与 `health` 构造并校验真实 Runtime Parts,区分 assembly-ready、Core compatibility owner 和不可用扩展。 它们证明必需能力已注册,不把 Core 的 Network/Git/MCP compatibility marker 描述为外部服务实时可用。 -- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译。 +- 独立 CLI 测试与打包工作流;主 CI 的三平台 workspace check 同时覆盖 `bitfun-cli` 编译,发布归档在上传前校验 + SHA-256 摘要,并从解压后的目录执行 `--version` / `--help`。 上述切换不等于运行时 owner 已迁移,也不表示 CLI-P0 全部完成。CLI crate 仍以 `bitfun-core/product-full` 承载协调器、调度器、持久化、工具管线和部分 SDK v1 缺口,但 Peer Host 不再自行构造这些 owner;ACP 的 stdio、 @@ -140,7 +146,7 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop | OpenCode 来源发现与真实执行尚未形成完整闭环 | “来源可识别”容易被误解为“插件可执行” | 第一条闭环只完成一个无外部依赖的契约样例;取得真实 `execute` 并注册到 Tool Runtime 后才显示可用。 | | 当前 CLI 使用 `product-full`,OHOS target 图包含多组未验证的平台依赖 | 不能据依赖可解析、`hdc shell` 或移动 Remote App 推导 PC 本地 CLI/TUI 可用 | 问题与风险统一记录在平台规约;具体工作另立专题,HAP 不作为替代。 | | Product Capability 已有,但品牌、资源、默认策略和发行配置没有统一产品定义 | 白标需要修改多处常量和工作流,能力隐藏不等于后端禁用 | 产品定义只在组装/构建边界选择身份、资源、能力包、默认策略和发行事实。 | -| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;三平台编译由通用 workspace check 覆盖 | 真实模型审批/取消、Patch I/O 失败、PTY 与常规打包仍可能晚于 PR 发现 | 继续补进程级和 PTY 契约及 package smoke;避免为同一依赖图重复建立三平台编译矩阵。 | +| CLI 已有独立 Linux 测试,参数互斥、结果/envelope 序列化、前置失败和组装有 focused contract;Linux 与 Windows 分别运行启动页 PTY/ConPTY 生命周期冒烟,发布归档上传前完成 SHA-256 与解压执行验证 | 真实模型审批/取消、Chat 活动 turn、resize 渲染、Patch I/O 失败和终端故障注入仍可能晚于 PR 发现 | 继续补剩余进程级故障契约;避免为同一依赖图重复建立三平台编译矩阵。 | ## 3. 分阶段产品需求 @@ -149,9 +155,10 @@ BitFun CLI 应成为可独立安装和发布的 Agent 产品,而不是 Desktop CLI-P0 的目标是建立后续功能补齐所需的稳定边界,不改变现有用户主路径。 CLI-P0 不是一个统一重构 PR。静态 profile、真实 Runtime Services、Runtime Parts、调用级审批、共享事件源和 -本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费、TUI -进一步拆分和 package smoke 仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 SDK runtime; -真实模型、PTY 与权限失败等进程级验收仍需另行完成,不能由本次运行时切换代替。 +本地 Agent 纵向入口已接入;旧门面仅在后续 owner 迁移的行为等价成立后退出。配置解释、产品定制消费和 TUI +进一步拆分仍需独立交付。CLI 托管的 ACP 服务端已独立切换到 ACP profile 与组装后的 SDK runtime;启动页 +PTY/ConPTY 生命周期冒烟测试与发布归档冒烟测试已存在,真实模型、Chat 活动 turn、resize 渲染、终端故障注入与 +权限失败等完整进程级验收仍需另行完成。 其余工作独立立项,不能与 profile 迁移互相充当完成条件: @@ -589,8 +596,10 @@ CLI Agent 能力加强必须落在共享 Agent Runtime、Tool Runtime 或 Harnes | Action/Keymap | registry 唯一性、Slash/Palette/Help/dispatch 一致、配置键位真实输入、冲突来源和终端恢复 fallback | 通用 `cargo check --workspace` 负责三平台 CLI 编译保护;独立 CLI CI 运行 -`cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。已落地的 focused 协议契约进入该测试;完整进程/PTY 矩阵与打包 smoke -仍按对应切片补入门禁,不能由序列化单测代替。 +`cargo test --locked -p bitfun-cli -p bitfun-acp -p bitfun-agent-runtime`。Linux 启动页 PTY 生命周期冒烟随独立 CLI +测试运行,Windows 启动页 ConPTY 生命周期冒烟复用通用 Windows job;发布归档在上传前完成 SHA-256 与解压执行 +验证。完整模型、Chat 活动 turn、resize 渲染与故障进程矩阵仍按对应切片补入门禁,不能由序列化单测或基础冒烟 +测试代替。 ### 10.2 阶段退出条件 diff --git a/docs/plans/core-decomposition-plan.md b/docs/plans/core-decomposition-plan.md index b21512e0e1..afd8efbf86 100644 --- a/docs/plans/core-decomposition-plan.md +++ b/docs/plans/core-decomposition-plan.md @@ -28,7 +28,7 @@ | Agent Runtime SDK | 已有无 `bitfun-core` 依赖的 v1 preview 门面和 smoke test | 发布边界仍需真实嵌入方证明 | | 插件运行时 | 现有路径只覆盖 BitFun 原生包和 OpenCode custom tool 静态名称预览 | 不能据通用 envelope 或静态候选扩张稳定 ABI | | Relay | room/device 状态、account/sync 存储、asset store 与 HTTP/WebSocket router 已归属 `services/relay-service`,standalone 与 embedded 入口同向消费;embedded 宿主逻辑仍在 assembly 兼容路径 | Cargo metadata 门禁覆盖 workspace、独立 manifest、normal/build/dev 依赖及 optional/target 变体;宿主归位是独立后续工作 | -| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;发布工作流负责打包 | 参数/序列化/前置失败和组装已有 focused contract;真实模型/PTY、Patch I/O 失败与常规 package smoke 仍需补齐 | +| CLI CI | 独立 Linux job 运行 CLI test,通用三平台 workspace check 覆盖 CLI 编译;Linux PTY 与 Windows ConPTY 有启动页生命周期进程冒烟,发布归档上传前校验 SHA-256 并解压执行 | 参数/序列化/前置失败和组装已有 focused contract;真实模型、Chat 活动 turn、resize 渲染、终端故障注入与 Patch I/O 失败仍需补齐 | ## 3. 目标依赖与归属 @@ -74,11 +74,11 @@ Peer Host 的 Runtime 接入和跨 Relay/Desktop/Web 的协议切换保持独立 1. 以真实调用方和行为等价测试补齐 SDK 端口,逐项缩小模型更新、分支、用量、快照和持久化维护兼容面。 2. 继续迁移 ACP 尚未接入 SDK 的持久化历史、模型/模式和 MCP 操作;ACP stdio 与协议投影生命周期保留在接口入口。 -3. 继续拆分 TUI 副作用边界并补 package smoke,不以大规模重写替代现有回归保护。 +3. 继续按真实故障样例拆分 TUI 副作用边界,不以大规模重写替代现有回归保护。 当前 assembly 切换条件已经满足:CLI 生产入口消费真实组装结果,目标链路没有第二套状态,独立测试与三平台 -编译门禁存在。CLI-P0 整体退出条件尚未满足;真实模型/PTY 协议矩阵、兼容门面退出、ACP/Desktop 切换和 -package smoke 需分别验收。 +编译门禁存在,启动页 PTY/ConPTY 生命周期与发布归档冒烟测试已接入门禁。CLI-P0 整体退出条件尚未满足; +真实模型交互、Chat 活动 turn、resize 渲染、终端故障注入、兼容门面退出以及 ACP/Desktop 切换仍需分别验收。 ### 4.3 依次切换 ACP 与 Desktop diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 60a8644df4..7006d231ef 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -75,6 +75,7 @@ tracing-subscriber = { workspace = true } tempfile = "3" sha2 = { workspace = true } hex = { workspace = true } +portable-pty = { workspace = true } [features] default = [] diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index b87b9101d3..eae0e7392c 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1753,6 +1753,7 @@ impl ChatMode { let mut subagent_parent_tools: HashMap = HashMap::new(); let mut last_spinner_redraw = Instant::now(); let mut pending_resize_at: Option = None; + let mut event_reader = crate::ui::input::EventReader::default(); let mut fatal_event_stream_error: Option = None; let spinner_redraw_interval = Duration::from_millis(SPINNER_REDRAW_INTERVAL_MS); let resize_redraw_debounce = Duration::from_millis(RESIZE_REDRAW_DEBOUNCE_MS); @@ -2124,77 +2125,36 @@ impl ChatMode { } // 3. Process terminal input - if crossterm::event::poll(Duration::from_millis(16))? { - if let Ok(first_event) = crossterm::event::read() { - // Batch-collect all immediately available events (paste detection). - // On Windows, bracketed paste is broken (crossterm #962) and - // pasted text arrives as rapid Key events with Enter mixed in. - let mut events = vec![first_event]; - // Short wait to let rapid paste events arrive in the same batch. - // Duration::ZERO would split pastes across loop iterations. - while crossterm::event::poll(Duration::from_millis(5))? { - if let Ok(ev) = crossterm::event::read() { - events.push(ev); - } else { - break; - } - } - - // Detect if this batch looks like a paste: multiple Key events - // that include at least one Enter and at least one printable char. - let is_paste_batch = if events.len() > 2 { - let mut has_enter = false; - let mut has_char = false; - for ev in &events { - if let Event::Key(k) = ev { - if k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat { - match k.code { - KeyCode::Enter => has_enter = true, - KeyCode::Char(c) if !c.is_control() => has_char = true, - _ => {} - } - } - } - } - has_enter && has_char - } else { - false - }; - - if is_paste_batch { - // Treat entire batch as pasted text - let mut paste_buf = String::new(); - let mut non_key_events = Vec::new(); - for ev in events { - match ev { - Event::Key(k) - if k.kind == KeyEventKind::Press - || k.kind == KeyEventKind::Repeat => - { - match k.code { - KeyCode::Char(c) => paste_buf.push(c), - KeyCode::Enter => paste_buf.push('\n'), - _ => {} - } - } - other => non_key_events.push(other), + if let Some(events) = event_reader.read_event_batch(Duration::from_millis(16))? { + for event in events { + match event { + Event::Key(key) => { + if let Some(reason) = self.handle_key_event( + key, + &mut chat_view, + &mut chat_state, + &rt_handle, + )? { + Self::apply_exit_reason( + reason, + ChatEventContext { + this: self, + chat_view: &mut chat_view, + chat_state: &mut chat_state, + session_id: &mut session_id, + rt_handle: &rt_handle, + should_quit: &mut should_quit, + exit_reason: &mut exit_reason, + }, + ); } - } - if !paste_buf.is_empty() { - let normalized = paste_buf.replace("\r\n", "\n").replace('\r', "\n"); - if chat_view.login_form_visible() { - chat_view.login_form_insert_paste(&normalized); - } else { - for c in normalized.chars() { - chat_view.handle_char(c); - } + if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { + needs_redraw = true; } - needs_redraw = true; } - // Process any non-key events that were mixed in - for ev in non_key_events { + other => { let outcome = Self::handle_non_key_event( - ev, + other, ChatEventContext { this: self, chat_view: &mut chat_view, @@ -2212,58 +2172,6 @@ impl ChatMode { pending_resize_at = Some(Instant::now()); } } - } else { - // Normal single/few events — process each individually - for ev in events { - match ev { - Event::Key(key) => { - if let Some(reason) = self.handle_key_event( - key, - &mut chat_view, - &mut chat_state, - &rt_handle, - )? { - Self::apply_exit_reason( - reason, - ChatEventContext { - this: self, - chat_view: &mut chat_view, - chat_state: &mut chat_state, - session_id: &mut session_id, - rt_handle: &rt_handle, - should_quit: &mut should_quit, - exit_reason: &mut exit_reason, - }, - ); - } - if key.kind == KeyEventKind::Press - || key.kind == KeyEventKind::Repeat - { - needs_redraw = true; - } - } - other => { - let outcome = Self::handle_non_key_event( - other, - ChatEventContext { - this: self, - chat_view: &mut chat_view, - chat_state: &mut chat_state, - session_id: &mut session_id, - rt_handle: &rt_handle, - should_quit: &mut should_quit, - exit_reason: &mut exit_reason, - }, - )?; - if outcome.request_redraw { - needs_redraw = true; - } - if outcome.resize_seen { - pending_resize_at = Some(Instant::now()); - } - } - } - } } } } @@ -2882,11 +2790,11 @@ impl ChatMode { context.chat_view.mcp_add_dialog_handle_paste(&text); } else if context.chat_view.login_form_visible() { context.chat_view.login_form_insert_paste(&text); - } else { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for c in normalized.chars() { - context.chat_view.handle_char(c); - } + } else if context.chat_state.permission_prompt.is_none() + && context.chat_state.question_prompt.is_none() + && !context.this.any_popup_visible(context.chat_view) + { + context.chat_view.insert_paste(&text); } outcome.request_redraw = true; } @@ -3636,10 +3544,7 @@ impl ChatMode { fn paste_clipboard(&self, chat_view: &mut ChatView) { if let Ok(text) = Clipboard::new().and_then(|mut clipboard| clipboard.get_text()) { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for character in normalized.chars() { - chat_view.handle_char(character); - } + chat_view.insert_paste(&text); } } diff --git a/src/apps/cli/src/ui/chat/input.rs b/src/apps/cli/src/ui/chat/input.rs index a25226bdb9..284f5246f9 100644 --- a/src/apps/cli/src/ui/chat/input.rs +++ b/src/apps/cli/src/ui/chat/input.rs @@ -43,6 +43,11 @@ impl ChatView { self.refresh_command_menu(); } + pub(crate) fn insert_paste(&mut self, text: &str) { + self.text_input.insert_paste(text); + self.refresh_command_menu(); + } + pub(crate) fn handle_newline(&mut self) { self.text_input.handle_newline(); self.refresh_command_menu(); diff --git a/src/apps/cli/src/ui/input.rs b/src/apps/cli/src/ui/input.rs new file mode 100644 index 0000000000..97198258d4 --- /dev/null +++ b/src/apps/cli/src/ui/input.rs @@ -0,0 +1,348 @@ +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use std::io; +use std::time::{Duration, Instant}; + +const FOLLOW_UP_WAIT: Duration = Duration::from_millis(5); +const CONTINUATION_IDLE_WAIT: Duration = Duration::from_millis(50); +const MAX_BATCH_DURATION: Duration = Duration::from_millis(50); +// Bound one read cycle by both count and time so sustained input cannot starve +// redraws. EventReader keeps only enough state to preserve an Enter that lands +// in the short tail of a rapid Windows paste. +const MAX_BATCH_EVENTS: usize = 256; +// Require a substantial cut batch before treating newline-free keys as paste; +// short key sequences must retain navigation and form semantics. +const LARGE_TEXT_BATCH_MIN_EVENTS: usize = 32; + +#[derive(Default)] +pub(crate) struct EventReader { + continuing_text_burst: bool, +} + +impl EventReader { + /// Read and normalize one bounded burst of terminal input. + pub(crate) fn read_event_batch(&mut self, timeout: Duration) -> io::Result>> { + let poll_started = Instant::now(); + if self.continuing_text_burst { + if !event::poll(CONTINUATION_IDLE_WAIT)? { + self.continuing_text_burst = false; + let remaining = timeout.saturating_sub(poll_started.elapsed()); + if !event::poll(remaining)? { + return Ok(None); + } + } + } else if !event::poll(timeout)? { + return Ok(None); + } + + let batch_started = Instant::now(); + let mut events = Vec::with_capacity(8); + events.push(event::read()?); + let mut batch_was_cut = false; + loop { + if events.len() >= MAX_BATCH_EVENTS { + batch_was_cut = true; + break; + } + + let remaining = MAX_BATCH_DURATION.saturating_sub(batch_started.elapsed()); + if remaining.is_zero() { + batch_was_cut = true; + break; + } + if !event::poll(remaining.min(FOLLOW_UP_WAIT))? { + break; + } + events.push(event::read()?); + } + + Ok(Some(self.normalize_batch(events, batch_was_cut))) + } + + fn normalize_batch(&mut self, events: Vec, batch_was_cut: bool) -> Vec { + let continues_previous = self.continuing_text_burst; + self.continuing_text_burst = batch_was_cut && rapid_text_candidate(&events); + normalize_event_batch(events, continues_previous, batch_was_cut) + } +} + +fn rapid_text_candidate(events: &[Event]) -> bool { + let forbidden_modifiers = KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META; + + let mut has_active_text = false; + for event in events { + match event { + Event::Key(key) + if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => + { + let text_key = matches!( + key.code, + KeyCode::Char(character) if !character.is_control() + ) || matches!(key.code, KeyCode::Enter | KeyCode::Tab); + if key.modifiers.intersects(forbidden_modifiers) || !text_key { + return false; + } + has_active_text = true; + } + Event::Key(_) | Event::Resize(_, _) => {} + _ => return false, + } + } + has_active_text +} + +fn normalize_event_batch( + events: Vec, + continues_previous: bool, + batch_was_cut: bool, +) -> Vec { + let mut active_key_count = 0; + let mut has_enter = false; + let mut has_printable = false; + let mut rapid_paste_text = String::new(); + let mut rapid_paste_eligible = true; + let forbidden_modifiers = KeyModifiers::CONTROL + | KeyModifiers::ALT + | KeyModifiers::SUPER + | KeyModifiers::HYPER + | KeyModifiers::META; + + for event in &events { + match event { + Event::Key(key) + if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat => + { + active_key_count += 1; + if key.modifiers.intersects(forbidden_modifiers) { + rapid_paste_eligible = false; + continue; + } + + match key.code { + KeyCode::Char(character) if !character.is_control() => { + has_printable = true; + rapid_paste_text.push(character); + } + KeyCode::Enter => { + has_enter = true; + rapid_paste_text.push('\n'); + } + KeyCode::Tab => rapid_paste_text.push('\t'), + _ => rapid_paste_eligible = false, + } + } + Event::Paste(_) => rapid_paste_eligible = false, + _ => {} + } + } + + let current_batch_looks_like_paste = active_key_count >= 3 && has_printable && has_enter; + let large_cut_text_batch = + batch_was_cut && active_key_count >= LARGE_TEXT_BATCH_MIN_EVENTS && has_printable; + let continues_multiline_paste = continues_previous && active_key_count > 0 && has_enter; + if rapid_paste_eligible + && (current_batch_looks_like_paste || large_cut_text_batch || continues_multiline_paste) + { + let mut normalized = Vec::with_capacity(events.len() - active_key_count + 1); + normalized.push(Event::Paste(rapid_paste_text)); + normalized.extend(events.into_iter().filter(|event| { + !matches!(event, Event::Key(key) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat) + })); + return normalized; + } + + events +} + +#[cfg(test)] +mod tests { + use super::{normalize_event_batch, EventReader, MAX_BATCH_EVENTS}; + use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; + + fn key(code: KeyCode) -> Event { + key_with(code, KeyModifiers::NONE, KeyEventKind::Press) + } + + fn key_with(code: KeyCode, modifiers: KeyModifiers, kind: KeyEventKind) -> Event { + Event::Key(KeyEvent { + code, + modifiers, + kind, + state: KeyEventState::empty(), + }) + } + + #[test] + fn rapid_printable_keys_with_enter_become_one_paste_event() { + let normalized = normalize_event_batch( + vec![ + key(KeyCode::Char('a')), + key_with(KeyCode::Enter, KeyModifiers::NONE, KeyEventKind::Repeat), + key(KeyCode::Char('b')), + Event::Resize(120, 40), + ], + false, + false, + ); + + assert_eq!(normalized.len(), 2); + assert!(matches!(&normalized[0], Event::Paste(text) if text == "a\nb")); + assert_eq!(normalized[1], Event::Resize(120, 40)); + } + + #[test] + fn two_keys_remain_individual_input_events() { + let normalized = normalize_event_batch( + vec![key(KeyCode::Char('a')), key(KeyCode::Enter)], + false, + false, + ); + + assert_eq!(normalized.len(), 2); + assert!(matches!(normalized[0], Event::Key(_))); + assert!(matches!(normalized[1], Event::Key(_))); + } + + #[test] + fn explicit_paste_keeps_content_for_the_focused_input_owner() { + let normalized = + normalize_event_batch(vec![Event::Paste("a\r\nb\rc".to_string())], false, false); + + assert_eq!(normalized, vec![Event::Paste("a\r\nb\rc".to_string())]); + } + + #[test] + fn command_chords_are_never_collapsed_into_paste() { + let normalized = normalize_event_batch( + vec![ + key_with( + KeyCode::Char('c'), + KeyModifiers::CONTROL, + KeyEventKind::Press, + ), + key(KeyCode::Enter), + key(KeyCode::Char('x')), + ], + false, + false, + ); + + assert_eq!(normalized.len(), 3); + assert!(matches!(normalized[0], Event::Key(_))); + } + + #[test] + fn non_text_keys_are_never_dropped_from_a_candidate_batch() { + let normalized = normalize_event_batch( + vec![ + key(KeyCode::Char('a')), + key(KeyCode::Enter), + key(KeyCode::Left), + ], + false, + false, + ); + + assert_eq!(normalized.len(), 3); + assert!(matches!(normalized[2], Event::Key(key) if key.code == KeyCode::Left)); + } + + #[test] + fn a_cut_large_printable_batch_becomes_one_paste_event() { + let mut reader = EventReader::default(); + let normalized = reader.normalize_batch( + (0..MAX_BATCH_EVENTS) + .map(|_| key(KeyCode::Char('a'))) + .collect(), + true, + ); + + assert_eq!(normalized.len(), 1); + assert!(matches!(&normalized[0], Event::Paste(text) if text.len() == MAX_BATCH_EVENTS)); + } + + #[test] + fn an_enter_tail_after_a_saturated_text_batch_stays_paste_input() { + let mut reader = EventReader::default(); + let first_batch = reader.normalize_batch( + (0..MAX_BATCH_EVENTS) + .map(|_| key(KeyCode::Char('a'))) + .collect(), + true, + ); + let tail_batch = reader.normalize_batch(vec![key(KeyCode::Enter)], false); + + assert!(first_batch.iter().chain(&tail_batch).all(|event| { + !matches!(event, Event::Key(key) if key.kind == KeyEventKind::Press && key.code == KeyCode::Enter) + })); + } + + #[test] + fn a_tab_inside_rapid_multiline_text_is_kept_as_paste_content() { + let normalized = normalize_event_batch( + vec![ + key(KeyCode::Char('a')), + key(KeyCode::Tab), + key(KeyCode::Char('b')), + key(KeyCode::Enter), + ], + false, + false, + ); + + assert_eq!(normalized, vec![Event::Paste("a\tb\n".to_string())]); + } + + #[test] + fn a_tab_inside_a_short_single_line_batch_keeps_key_routing() { + let normalized = normalize_event_batch( + vec![key(KeyCode::Char('a')), key(KeyCode::Tab)], + false, + false, + ); + + assert_eq!(normalized.len(), 2); + assert!(normalized + .iter() + .all(|event| matches!(event, Event::Key(_)))); + } + + #[test] + fn release_events_do_not_trigger_or_disappear_during_normalization() { + let released = key_with( + KeyCode::Char('a'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + let normalized = normalize_event_batch( + vec![ + released.clone(), + key(KeyCode::Enter), + key(KeyCode::Char('b')), + ], + false, + false, + ); + + assert_eq!(normalized.len(), 3); + assert_eq!(normalized[0], released); + } + + #[test] + fn a_release_only_batch_does_not_arm_paste_continuation() { + let released = key_with( + KeyCode::Char('a'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + let mut reader = EventReader::default(); + reader.normalize_batch(vec![released; MAX_BATCH_EVENTS], true); + + let tail = reader.normalize_batch(vec![key(KeyCode::Enter)], false); + + assert!(matches!(tail[0], Event::Key(key) if key.code == KeyCode::Enter)); + } +} diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 983c2ca857..4097730c9e 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod chat; pub(crate) mod command_menu; pub(crate) mod command_palette; mod diff_render; +pub(crate) mod input; pub(crate) mod login_form; mod markdown; pub(crate) mod mcp_add_dialog; diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 362fb9fa57..39ad5c0f8f 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -27,7 +27,7 @@ use crate::config::CliConfig; /// - Model/Agent/Session/Skill/Subagent selector popups /// - Random tips use anyhow::Result; -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ backend::Backend, layout::{Alignment, Constraint, Direction, Layout, Rect}, @@ -330,6 +330,7 @@ impl StartupPage { pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result { terminal.clear()?; + let mut event_reader = crate::ui::input::EventReader::default(); loop { if self.login_form.is_visible() { @@ -337,81 +338,20 @@ impl StartupPage { } terminal.draw(|f| self.render(f))?; - if event::poll(Duration::from_millis(50))? { - if let Ok(first_event) = event::read() { - let mut events = vec![first_event]; - // Short wait to let rapid paste events arrive in the same batch. - // Duration::ZERO would split pastes across loop iterations. - while event::poll(Duration::from_millis(5))? { - if let Ok(ev) = event::read() { - events.push(ev); - } else { - break; - } - } - - // Paste detection: multiple key events with Enter + printable chars - let key_count = events - .iter() - .filter(|e| matches!(e, Event::Key(k) if k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat)) - .count(); - let has_enter = events.iter().any(|e| { - matches!(e, Event::Key(k) if (k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat) && k.code == KeyCode::Enter) - }); - let has_printable = events.iter().any(|e| { - matches!(e, Event::Key(k) if (k.kind == KeyEventKind::Press || k.kind == KeyEventKind::Repeat) && matches!(k.code, KeyCode::Char(_))) - }); - let is_paste_batch = key_count > 1 && has_enter && has_printable; - - if is_paste_batch { - let mut paste_buf = String::new(); - let mut non_key_events = Vec::new(); - for ev in events { - match ev { - Event::Key(k) - if k.kind == KeyEventKind::Press - || k.kind == KeyEventKind::Repeat => - { - match k.code { - KeyCode::Char(c) => paste_buf.push(c), - KeyCode::Enter => paste_buf.push('\n'), - _ => {} - } - } - other => non_key_events.push(other), - } - } - if !paste_buf.is_empty() { - if self.login_form.is_visible() { - self.login_form.insert_paste(&paste_buf); - } else { - self.text_input.insert_paste(&paste_buf); - self.refresh_command_menu(); - } - } - for ev in non_key_events { - if let Some(result) = self.handle_non_key_event(ev, terminal)? { + if let Some(events) = event_reader.read_event_batch(Duration::from_millis(50))? { + for event in events { + match event { + Event::Key(key) + if key.kind == KeyEventKind::Press + || key.kind == KeyEventKind::Repeat => + { + if let Some(result) = self.handle_key(key) { return Ok(result); } } - } else { - for ev in events { - match ev { - Event::Key(key) - if key.kind == KeyEventKind::Press - || key.kind == KeyEventKind::Repeat => - { - if let Some(result) = self.handle_key(key) { - return Ok(result); - } - } - other => { - if let Some(result) = - self.handle_non_key_event(other, terminal)? - { - return Ok(result); - } - } + other => { + if let Some(result) = self.handle_non_key_event(other, terminal)? { + return Ok(result); } } } @@ -456,7 +396,7 @@ impl StartupPage { Event::Paste(text) => { if self.login_form.is_visible() { self.login_form.insert_paste(&text); - } else { + } else if self.info_popup.is_none() && !self.any_popup_visible() { self.text_input.insert_paste(&text); self.refresh_command_menu(); } diff --git a/src/apps/cli/src/ui/text_input.rs b/src/apps/cli/src/ui/text_input.rs index a5373f522c..301f9a8d51 100644 --- a/src/apps/cli/src/ui/text_input.rs +++ b/src/apps/cli/src/ui/text_input.rs @@ -199,9 +199,29 @@ impl TextInput { } pub(super) fn insert_paste(&mut self, text: &str) { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for c in normalized.chars() { - self.handle_char(c); + let mut normalized = String::with_capacity(text.len()); + let mut characters = text.chars().peekable(); + while let Some(character) = characters.next() { + match character { + '\r' => { + if characters.peek() == Some(&'\n') { + characters.next(); + } + normalized.push('\n'); + } + '\t' => normalized.push_str(" "), + '\n' => normalized.push('\n'), + character if !character.is_control() && character != '\u{0}' => { + normalized.push(character); + } + _ => {} + } + } + + if !normalized.is_empty() { + let byte_pos = self.char_pos_to_byte_pos(self.cursor); + self.cursor += normalized.chars().count(); + self.input.insert_str(byte_pos, &normalized); } } @@ -436,4 +456,27 @@ mod tests { assert_eq!(TextInput::avail_width(0, usize::MAX), 1); assert_eq!(TextInput::avail_width(u16::MAX, 0), u16::MAX as usize); } + + #[test] + fn paste_normalizes_newlines_and_expands_tabs_without_dropping_text() { + let mut input = TextInput::new(); + input.set_text("ac"); + input.cursor = 1; + + input.insert_paste("b\t\r\nd"); + + assert_eq!(input.text(), "ab \ndc"); + assert_eq!(input.cursor, 8); + } + + #[test] + fn large_paste_is_inserted_without_changing_content() { + let mut input = TextInput::new(); + let pasted = "x".repeat(64 * 1024); + + input.insert_paste(&pasted); + + assert_eq!(input.text(), pasted); + assert_eq!(input.cursor, 64 * 1024); + } } diff --git a/src/apps/cli/tests/tui_terminal_process.rs b/src/apps/cli/tests/tui_terminal_process.rs new file mode 100644 index 0000000000..b14eabe16e --- /dev/null +++ b/src/apps/cli/tests/tui_terminal_process.rs @@ -0,0 +1,197 @@ +use portable_pty::{native_pty_system, CommandBuilder, PtyPair, PtySize}; +use std::io::{Read, Write}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; + +const INITIAL_SIZE: PtySize = PtySize { + rows: 30, + cols: 100, + pixel_width: 0, + pixel_height: 0, +}; +const RESIZED_SIZE: PtySize = PtySize { + rows: 40, + cols: 120, + pixel_width: 0, + pixel_height: 0, +}; + +#[test] +fn interactive_startup_survives_resize_multiline_input_and_emits_cleanup() { + let storage = tempfile::tempdir().expect("create isolated CLI storage"); + let user_root = storage.path().join("user-root"); + let home_root = storage.path().join("home"); + std::fs::create_dir_all(&user_root).expect("create isolated user root"); + std::fs::create_dir_all(&home_root).expect("create isolated home root"); + + let pair = native_pty_system() + .openpty(INITIAL_SIZE) + .expect("open native PTY"); + let PtyPair { master, slave } = pair; + + let mut command = CommandBuilder::new(env!("CARGO_BIN_EXE_bitfun-cli")); + command.cwd(storage.path()); + command.env("BITFUN_E2E_STORAGE_GUARD", "1"); + command.env("BITFUN_E2E_USER_ROOT", &user_root); + command.env("BITFUN_E2E_HOME", &home_root); + command.env("HOME", &home_root); + command.env("USERPROFILE", &home_root); + command.env("TERM", "xterm-256color"); + + let mut child = slave + .spawn_command(command) + .expect("spawn bitfun-cli in native PTY"); + drop(slave); + + let mut reader = master.try_clone_reader().expect("clone PTY reader"); + let captured = Arc::new(Mutex::new(Vec::new())); + let reader_capture = Arc::clone(&captured); + let reader_thread = thread::spawn(move || { + let mut chunk = [0_u8; 4096]; + while let Ok(read) = reader.read(&mut chunk) { + if read == 0 { + break; + } + reader_capture + .lock() + .expect("lock captured PTY output") + .extend_from_slice(&chunk[..read]); + } + }); + + wait_for_output(&captured, "\x1b[?2004h", Duration::from_secs(30)).unwrap_or_else(|| { + terminate(&mut child); + panic!( + "interactive startup did not enable bracketed paste; output:\n{}", + captured_output(&captured) + ); + }); + #[cfg(unix)] + assert!( + captured_output(&captured).contains("\x1b[?1049h"), + "interactive TUI must enter the alternate screen" + ); + + master.resize(RESIZED_SIZE).expect("resize native PTY"); + assert_eq!( + master.get_size().expect("read resized PTY dimensions"), + RESIZED_SIZE + ); + + let mut writer = master.take_writer().expect("take PTY writer"); + #[cfg(unix)] + writer + .write_all(b"\x1b[200~alpha\r\nbeta\x1b[201~") + .expect("send bracketed paste"); + #[cfg(windows)] + { + let mut rapid_input = b"alpha".to_vec(); + rapid_input.extend(std::iter::repeat_n(b'a', 251)); + rapid_input.extend_from_slice(b"\rbeta"); + writer + .write_all(&rapid_input) + .expect("send rapid multiline key input across the batch boundary"); + } + writer.flush().expect("flush terminal input"); + + wait_for_output(&captured, "alpha", Duration::from_secs(15)).unwrap_or_else(|| { + terminate(&mut child); + panic!( + "interactive startup did not render multiline input; output:\n{}", + captured_output(&captured) + ); + }); + wait_for_output(&captured, "beta", Duration::from_secs(15)).unwrap_or_else(|| { + terminate(&mut child); + panic!( + "interactive startup did not render the multiline input tail; output:\n{}", + captured_output(&captured) + ); + }); + assert!( + !captured_output(&captured).contains("Welcome to BitFun CLI!"), + "multiline input was submitted instead of remaining in the startup editor" + ); + + writer.write_all(&[0x03]).expect("send Ctrl+C"); + writer.flush().expect("flush Ctrl+C"); + + let deadline = Instant::now() + Duration::from_secs(15); + let status = loop { + if let Some(status) = child.try_wait().expect("poll bitfun-cli process") { + break status; + } + if Instant::now() >= deadline { + terminate(&mut child); + panic!( + "interactive startup did not exit after Ctrl+C; output:\n{}", + captured_output(&captured) + ); + } + thread::sleep(Duration::from_millis(25)); + }; + + drop(writer); + drop(master); + reader_thread.join().expect("join PTY reader"); + + let output = captured_output(&captured); + assert!( + status.success(), + "unexpected process status {status}:\n{output}" + ); + assert!( + output.contains("alpha"), + "paste text was not rendered:\n{output}" + ); + assert!( + output.contains("beta"), + "paste tail was not rendered:\n{output}" + ); + assert!( + !output.contains("[200~") && !output.contains("[201~"), + "bracketed-paste markers leaked into input:\n{output}" + ); + assert!( + output.contains("\x1b[?2004l"), + "bracketed paste was not disabled:\n{output}" + ); + #[cfg(unix)] + assert!( + output.contains("\x1b[?1049l"), + "alternate screen was not left:\n{output}" + ); + assert!( + output.contains("\x1b[?25h"), + "cursor was not restored:\n{output}" + ); + assert!( + output.contains("Goodbye!"), + "clean exit was not reported:\n{output}" + ); +} + +fn wait_for_output( + captured: &Arc>>, + expected: &str, + timeout: Duration, +) -> Option<()> { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if captured_output(captured).contains(expected) { + return Some(()); + } + thread::sleep(Duration::from_millis(25)); + } + None +} + +fn captured_output(captured: &Arc>>) -> String { + String::from_utf8_lossy(&captured.lock().expect("lock captured PTY output")).into_owned() +} + +fn terminate(child: &mut Box) { + let _ = child.kill(); + let _ = child.wait(); +}