From 8d39eb311cae0a0f91af4d50a4250ff1d82eb1ba Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Sat, 27 Jun 2026 16:36:23 +0800 Subject: [PATCH 1/3] fix(tui): add 100ms debounce to handle_ctrl_c for Windows ConPTY duplicate events Windows Terminal (ConPTY) may deliver two KeyDown events for a single Ctrl+C press (native KEY_EVENT + ctrl_handler injected event). Without debounce, both events reach handle_ctrl_c within ~0-1ms, causing the quit-pending logic to trigger Quit on a single keypress. Added a 100ms minimum interval check: if quit_pending_since was set less than 100ms ago, treat the event as a duplicate and ignore it. Fixes #85 Co-Authored-By: mimo-v2.5-pro --- peri-tui/src/event/keyboard.rs | 2 + peri-tui/src/event/keyboard/normal_keys.rs | 10 ++++ .../2026-06-27-ctrl-c-double-exit-windows.md | 57 +++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 spec/issues/2026-06-27-ctrl-c-double-exit-windows.md diff --git a/peri-tui/src/event/keyboard.rs b/peri-tui/src/event/keyboard.rs index 08213773..8b60d26a 100644 --- a/peri-tui/src/event/keyboard.rs +++ b/peri-tui/src/event/keyboard.rs @@ -264,6 +264,8 @@ mod tests { ); assert!(!matches!(r1, Some(Action::Quit)), "第一次不应 Quit"); + // 等待超过防抖窗口(100ms),模拟真实双击 + tokio::time::sleep(std::time::Duration::from_millis(150)).await; // 第二次 Ctrl+C(2 秒内)→ 真正退出 let r2 = handle_key_event(&mut app, ctrl_c).unwrap(); assert!(matches!(r2, Some(Action::Quit)), "第二次 Ctrl+C 应返回 Quit"); diff --git a/peri-tui/src/event/keyboard/normal_keys.rs b/peri-tui/src/event/keyboard/normal_keys.rs index 089d096e..dce595bb 100644 --- a/peri-tui/src/event/keyboard/normal_keys.rs +++ b/peri-tui/src/event/keyboard/normal_keys.rs @@ -438,6 +438,12 @@ fn handle_ctrl_c(app: &mut App) -> Option { // quit-pending: 2 秒内连按两次退出 if let Some(since) = app.global_ui.quit_pending_since { + // 防抖:100ms 内的重复事件视为同一次按键,忽略。 + // Windows Terminal (ConPTY) 下 ctrl_handler 注入的 KeyDown 与原生 + // KeyDown 间隔约 0-1ms,不加防抖会误触发退出。 + if since.elapsed() < std::time::Duration::from_millis(100) { + return None; + } if since.elapsed() < std::time::Duration::from_secs(2) { return Some(Action::Quit); } else { @@ -717,6 +723,8 @@ mod tests { "空闲时应进入 quit-pending" ); + // 等待超过防抖窗口(100ms),模拟真实双击 + tokio::time::sleep(std::time::Duration::from_millis(150)).await; let result = handle_ctrl_c(&mut app); assert!( matches!(result, Some(Action::Quit)), @@ -730,6 +738,8 @@ mod tests { let _ = handle_ctrl_c(&mut app); assert!(app.global_ui.quit_pending_since.is_some()); + // 等待超过防抖窗口(100ms),模拟真实双击 + tokio::time::sleep(std::time::Duration::from_millis(150)).await; // 输入框有内容,第二次 Ctrl+C 仍应退出 app.session_mgr .current_mut() diff --git a/spec/issues/2026-06-27-ctrl-c-double-exit-windows.md b/spec/issues/2026-06-27-ctrl-c-double-exit-windows.md new file mode 100644 index 00000000..d6972aca --- /dev/null +++ b/spec/issues/2026-06-27-ctrl-c-double-exit-windows.md @@ -0,0 +1,57 @@ +# Ctrl+C 偶发直接退出(Windows Terminal) + +**状态**: Open +**创建日期**: 2026-06-27 +**严重程度**: P1 +**平台**: Windows (ConPTY / Windows Terminal) + +## 问题描述 + +在 Windows Terminal 下使用 cc-code 时,按一次 Ctrl+C 偶发直接退出程序,而非预期的中断 Agent 或进入 quit-pending 状态。 + +## 根因分析 + +### 核心问题:单次 Ctrl+C 产生两个 KeyDown 事件 + +`peri-tui/src/main.rs:449-498` 中注册了 `ctrl_handler`,在收到 `CTRL_C_EVENT` 时向 STD_INPUT 注入 KeyDown + KeyUp 事件对。 + +但在 Windows Terminal (ConPTY) 下,原始的 KEY_EVENT 可能残留在输入缓冲区中,导致 crossterm 读到**两个** KeyDown 事件: + +``` +时序: +1. crossterm 读到原生 KeyDown(Ctrl+C) + → handle_ctrl_c → loading=false → 设置 quit_pending_since = now +2. crossterm 读到 ctrl_handler 注入的 KeyDown(Ctrl+C) + → handle_ctrl_c → quit_pending_since 存在且 < 2s → Action::Quit → 退出 +``` + +两个事件间隔约 0-1ms,等同于单次按键。 + +### 为什么是偶发 + +- 取决于 Windows Terminal 版本和 ConPTY 行为(是否同时保留 KEY_EVENT) +- 取决于 `poll(50ms)` 时序——两个事件是否在同一 poll 周期 +- Agent 运行中(`loading=true`)时第一个 Ctrl+C 走 interrupt 路径不设置 `quit_pending_since`,不会触发退出 + +## 修复方案 + +在 `handle_ctrl_c` 的 quit-pending 判断前加 100ms 最小间隔防抖: + +```rust +if let Some(since) = app.global_ui.quit_pending_since { + if since.elapsed() < Duration::from_millis(100) { + return None; // 重复事件,忽略 + } + if since.elapsed() < Duration::from_secs(2) { + return Some(Action::Quit); + } + // ... +} +``` + +100ms 覆盖重复事件(0-1ms),不影响正常双击退出(人类间隔 200ms+)。 + +## 涉及文件 + +- `peri-tui/src/event/keyboard/normal_keys.rs:431-450` — `handle_ctrl_c` 函数 +- `peri-tui/src/main.rs:449-498` — Windows `ctrl_handler` 注入逻辑 From f16ce411a7587e223c0294902be412f0898a0cde Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Sat, 27 Jun 2026 16:58:44 +0800 Subject: [PATCH 2/3] fix(test): grep offset assertion use ends_with for path safety --- peri-middlewares/src/tools/filesystem/grep_test.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/peri-middlewares/src/tools/filesystem/grep_test.rs b/peri-middlewares/src/tools/filesystem/grep_test.rs index 2b6dd664..300dfdca 100644 --- a/peri-middlewares/src/tools/filesystem/grep_test.rs +++ b/peri-middlewares/src/tools/filesystem/grep_test.rs @@ -963,10 +963,17 @@ async fn test_grep_files_without_matches_with_offset() { result.starts_with("Found 2 files limit: 2, offset: 1"), "头部应反映 limit 和 offset: {result}" ); + assert_eq!(lines.len(), 3, "应有 header + 2 个文件: {result}"); assert!(lines[1].ends_with("b.txt"), "slice 第 1 项: {result}"); assert!(lines[2].ends_with("c.txt"), "slice 第 2 项: {result}"); - assert!(!result.contains("a.txt"), "a.txt 应被 offset 跳过: {result}"); - assert!(!result.contains("z.txt"), "z.txt 有匹配,不应出现在无匹配列表: {result}"); + assert!( + !lines.iter().skip(1).any(|l| l.ends_with("a.txt")), + "a.txt 应被 offset 跳过: {result}" + ); + assert!( + !lines.iter().skip(1).any(|l| l.ends_with("z.txt")), + "z.txt 有匹配,不应出现在无匹配列表: {result}" + ); } /// head_limit=0(unlimited)+ offset>0 边界组合 From f1e7c20bbedaec9f7aa8b73f9f88d9ec9780f4c4 Mon Sep 17 00:00:00 2001 From: wismyzhizi2018 Date: Sat, 27 Jun 2026 17:14:39 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(test):=20grep=20files=5Fwithout=5Fmatch?= =?UTF-8?q?es=20offset=20=E6=96=AD=E8=A8=80=E5=85=BC=E5=AE=B9=20persist=20?= =?UTF-8?q?hint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lines[N] 硬编码索引在 persist hint 附加行后错位,改用 contains 检查。 --- .../src/tools/filesystem/grep_test.rs | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/peri-middlewares/src/tools/filesystem/grep_test.rs b/peri-middlewares/src/tools/filesystem/grep_test.rs index 300dfdca..f8cb29af 100644 --- a/peri-middlewares/src/tools/filesystem/grep_test.rs +++ b/peri-middlewares/src/tools/filesystem/grep_test.rs @@ -958,22 +958,15 @@ async fn test_grep_files_without_matches_with_offset() { .unwrap(); // cfg(test) 下按 filename 升序:a/b/c/d/e // slice(1, 3) → b, c - let lines: Vec<&str> = result.lines().collect(); assert!( result.starts_with("Found 2 files limit: 2, offset: 1"), "头部应反映 limit 和 offset: {result}" ); - assert_eq!(lines.len(), 3, "应有 header + 2 个文件: {result}"); - assert!(lines[1].ends_with("b.txt"), "slice 第 1 项: {result}"); - assert!(lines[2].ends_with("c.txt"), "slice 第 2 项: {result}"); - assert!( - !lines.iter().skip(1).any(|l| l.ends_with("a.txt")), - "a.txt 应被 offset 跳过: {result}" - ); - assert!( - !lines.iter().skip(1).any(|l| l.ends_with("z.txt")), - "z.txt 有匹配,不应出现在无匹配列表: {result}" - ); + // 用 contains 而非 lines[N] 硬编码索引,兼容 persist hint 附加行 + assert!(result.contains("b.txt"), "slice 第 1 项: {result}"); + assert!(result.contains("c.txt"), "slice 第 2 项: {result}"); + assert!(!result.contains("a.txt"), "a.txt 应被 offset 跳过: {result}"); + assert!(!result.contains("z.txt"), "z.txt 有匹配,不应出现在无匹配列表: {result}"); } /// head_limit=0(unlimited)+ offset>0 边界组合