Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions peri-middlewares/src/tools/filesystem/grep_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -958,13 +958,13 @@ 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!(lines[1].ends_with("b.txt"), "slice 第 1 项: {result}");
assert!(lines[2].ends_with("c.txt"), "slice 第 2 项: {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}");
}
Expand Down
2 changes: 2 additions & 0 deletions peri-tui/src/event/keyboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 10 additions & 0 deletions peri-tui/src/event/keyboard/normal_keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,12 @@ fn handle_ctrl_c(app: &mut App) -> Option<Action> {

// 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 {
Expand Down Expand Up @@ -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)),
Expand All @@ -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()
Expand Down
57 changes: 57 additions & 0 deletions spec/issues/2026-06-27-ctrl-c-double-exit-windows.md
Original file line number Diff line number Diff line change
@@ -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` 注入逻辑
Loading