# Rewind 功能实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 实现对话回退（rewind）功能——双击 ESC 弹出消息选择器，用户选择回退节点后可撤销消息和文件变更。

**Architecture:** 分三层实现——(1) ACP Server 侧 `/rewind` Immediate Command 负责消息截断 + 文件回退 + 持久化删除；(2) TUI 侧新增 `RewindPrompt` 弹窗处理双击 ESC + 用户选择 + 二次确认；(3) `peri-agent` ThreadStore 新增按 ID 批量删除消息方法。TUI 通过 `session/prompt` 发送 `/rewind` 命令，Server 返回 `CompactCompleted` 事件通知 TUI 更新 UI，复用现有 compact 清理路径。

**Tech Stack:** Rust, tokio async, ratatui (TUI), sqlx (SQLite), serde_json (消息解析), git2 或 `std::process::Command`（git restore）

---

## File Structure

| 文件 | 操作 | 职责 |
|------|------|------|
| `peri-agent/src/thread/store.rs` | Modify | ThreadStore trait 新增 `delete_messages` 方法 |
| `peri-agent/src/thread/sqlite_store.rs` | Modify | SqliteThreadStore 实现 `delete_messages` |
| `peri-agent/src/thread/sqlite_store.rs` (tests) | Modify | 测试 `delete_messages` |
| `peri-acp/src/session/command/mod.rs` | Modify | 注册 RewindCommand |
| `peri-acp/src/session/command/rewind.rs` | Create | `/rewind` Immediate Command 实现 |
| `peri-tui/src/app/interaction.rs` | Modify | InteractionPrompt 新增 `Rewind` 变体 |
| `peri-tui/src/app/rewind_prompt.rs` | Create | `RewindPrompt` 数据结构 |
| `peri-tui/src/app/rewind_prompt_test.rs` | Create | RewindPrompt 单元测试 |
| `peri-tui/src/app/agent_ops/rewind.rs` | Create | rewind 弹窗逻辑（打开/确认/取消） |
| `peri-tui/src/event/keyboard/normal_keys.rs` | Modify | ESC 双击检测 + rewind 弹窗触发 |
| `peri-tui/src/event/keyboard/popups.rs` | Modify | Rewind 弹窗按键处理 |
| `peri-tui/src/ui/main_ui/popups/rewind.rs` | Create | Rewind 选择器渲染 |
| `peri-tui/src/ui/main_ui/popups/rewind_confirm.rs` | Create | Rewind 二次确认渲染 |
| `peri-tui/src/ui/main_ui/popups/mod.rs` | Modify | 注册 rewind 模块 |
| `peri-tui/src/ui/main_ui/mod.rs` | Modify | 弹窗渲染分发 + 高度计算 |
| `peri-tui/src/ui/main_ui/status_bar.rs` | Modify | Rewind 弹窗激活时的快捷键提示 |
| `peri-tui/src/app/global_ui_state.rs` | Modify | 新增 `rewind_pending_since` 字段 |

---

### Task 1: ThreadStore 新增 `delete_messages` 方法

**Files:**
- Modify: `peri-agent/src/thread/store.rs:7-56`
- Modify: `peri-agent/src/thread/sqlite_store.rs`

- [ ] **Step 1: 在 ThreadStore trait 中新增方法声明**

在 `peri-agent/src/thread/store.rs` 的 trait 中，`invalidate_context_cache` 之后添加：

```rust
    /// 按 message_id 列表精确删除消息，并刷新 cached_context。
    async fn delete_messages(
        &self,
        thread_id: &ThreadId,
        message_ids: &[crate::messages::MessageId],
    ) -> Result<()>;
```

- [ ] **Step 2: 在 SqliteThreadStore 中实现 `delete_messages`**

在 `peri-agent/src/thread/sqlite_store.rs` 中添加实现：

```rust
    async fn delete_messages(
        &self,
        thread_id: &ThreadId,
        message_ids: &[crate::messages::MessageId],
    ) -> Result<()> {
        if message_ids.is_empty() {
            return Ok(());
        }
        let mut tx = self.pool.begin().await?;

        for mid in message_ids {
            let id_str = mid.as_uuid().to_string();
            sqlx::query("DELETE FROM messages WHERE message_id = ?1 AND thread_id = ?2")
                .bind(&id_str)
                .bind(thread_id.as_str())
                .execute(&mut *tx)
                .await?;
        }

        // 更新 message_count
        let now = Utc::now().to_rfc3339();
        sqlx::query(
            "UPDATE threads SET updated_at = ?1,
                message_count = (SELECT COUNT(*) FROM messages WHERE thread_id = ?2)
             WHERE id = ?2",
        )
        .bind(&now)
        .bind(thread_id.as_str())
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;

        // 删除后必须刷新缓存
        self.invalidate_context_cache(thread_id).await?;
        Ok(())
    }
```

- [ ] **Step 3: 为 `filesystem.rs` 空实现添加桩**

`filesystem.rs` 也实现了 ThreadStore trait，需要添加空实现：

```rust
    async fn delete_messages(
        &self,
        _thread_id: &ThreadId,
        _message_ids: &[crate::messages::MessageId],
    ) -> Result<()> {
        Ok(())
    }
```

- [ ] **Step 4: 写测试**

在 `sqlite_store.rs` 的测试模块中（或对应 `_test.rs` 文件中）添加：

```rust
#[tokio::test]
async fn test_delete_messages() {
    let store = SqliteThreadStore::new_in_memory().await.unwrap();
    let thread_id = store.create_thread(ThreadMeta::new("/tmp")).await.unwrap();

    // 插入 3 条消息
    let msgs = vec![
        BaseMessage::human("first"),
        BaseMessage::ai("second"),
        BaseMessage::human("third"),
    ];
    let msg_ids: Vec<MessageId> = msgs.iter().map(|m| m.id()).collect();
    store.append_messages(&thread_id, &msgs).await.unwrap();

    // 删除中间那条
    store.delete_messages(&thread_id, &[msg_ids[1]]).await.unwrap();

    let loaded = store.load_messages(&thread_id).await.unwrap();
    assert_eq!(loaded.len(), 2);
    assert_eq!(loaded[0].content(), "first");
    assert_eq!(loaded[1].content(), "third");
}
```

注意：如果 `SqliteThreadStore` 没有 `new_in_memory()` 方法，需要在已有测试 helper 中创建临时 SQLite 实例。参考 `sqlite_store.rs` 现有测试的初始化方式。

- [ ] **Step 5: 运行测试验证**

Run: `cargo test -p peri-agent --lib -- test_delete_messages`
Expected: PASS

- [ ] **Step 6: Commit**

```bash
git add peri-agent/src/thread/store.rs peri-agent/src/thread/sqlite_store.rs peri-agent/src/thread/filesystem.rs
git commit -m "feat(agent): add delete_messages to ThreadStore for rewind support"
```

---

### Task 2: `/rewind` Immediate Command (ACP Server 侧)

**Files:**
- Create: `peri-acp/src/session/command/rewind.rs`
- Modify: `peri-acp/src/session/command/mod.rs`

- [ ] **Step 1: 创建 rewind 命令模块**

创建 `peri-acp/src/session/command/rewind.rs`：

```rust
//! `/rewind` 命令 — 回退对话到指定消息节点。
//!
//! 分析被回退的消息段，提取成功的 Write/Edit 调用，
//! 倒序执行文件恢复，截断消息历史，删除持久化记录。

use std::path::Path;

use peri_agent::{
    agent::events::AgentEvent as ExecutorEvent,
    messages::{BaseMessage, ContentBlock, MessageId},
};
use tracing::{debug, warn};

use super::{AgentCommand, CommandContext, CommandKind, CommandResult};
use crate::session::executor::PromptStopReason;

/// 对话回退命令。
pub struct RewindCommand;

impl RewindCommand {
    pub const NAME: &'static str = "rewind";
}

/// 从 BaseMessage 中提取的文件变更记录。
#[derive(Debug)]
enum FileChange {
    /// Write 工具：文件路径 + 写入内容
    Write { path: String, content: String },
    /// Edit 工具：文件路径 + old_string + new_string
    Edit {
        path: String,
        old_string: String,
        new_string: String,
    },
}

/// 从被回退的消息段中提取成功的文件变更。
///
/// 遍历消息中的 ToolUse (Write/Edit) + 对应的 ToolResult，
/// 只保留执行成功（`!is_error`）的调用。
fn extract_file_changes(messages: &[BaseMessage]) -> Vec<FileChange> {
    let mut changes = Vec::new();

    for msg in messages {
        match msg {
            BaseMessage::Ai { content, tool_calls, .. } => {
                // 优先从 tool_calls 提取（OpenAI 格式）
                for tc in tool_calls {
                    if tc.name == "Write" || tc.name == "Edit" {
                        if let Some(change) = parse_tool_call(&tc.name, &tc.arguments) {
                            changes.push(change);
                        }
                    }
                }

                // 从 content blocks 提取（Anthropic 格式）
                if tool_calls.is_empty() {
                    for block in content.content_blocks() {
                        if let ContentBlock::ToolUse { name, input, .. } = block {
                            if name == "Write" || name == "Edit" {
                                if let Some(change) = parse_tool_call(name, input) {
                                    changes.push(change);
                                }
                            }
                        }
                    }
                }
            }
            _ => {}
        }
    }

    changes
}

/// 解析工具调用参数为 FileChange。
fn parse_tool_call(name: &str, input: &serde_json::Value) -> Option<FileChange> {
    match name {
        "Write" => {
            let path = input.get("file_path")?.as_str()?.to_string();
            let content = input.get("content")?.as_str()?.to_string();
            Some(FileChange::Write { path, content })
        }
        "Edit" => {
            let path = input.get("file_path")?.as_str()?.to_string();
            let old_string = input.get("old_string")?.as_str()?.to_string();
            let new_string = input.get("new_string")?.as_str()?.to_string();
            Some(FileChange::Edit {
                path,
                old_string,
                new_string,
            })
        }
        _ => None,
    }
}

/// 倒序执行文件恢复（尽力模式，失败跳过）。
///
/// - Edit: swap old/new（将 new_string 替换回 old_string）
/// - Write: 删除文件，如果有 git 记录则 `git checkout HEAD -- path` 恢复
fn revert_file_changes(changes: &[FileChange], cwd: &str) -> Vec<String> {
    let mut failures = Vec::new();

    // 倒序恢复
    for change in changes.iter().rev() {
        match change {
            FileChange::Edit {
                path,
                old_string,
                new_string,
            } => {
                let full_path = Path::new(cwd).join(path);
                match std::fs::read_to_string(&full_path) {
                    Ok(content) => {
                        let reverted = content.replacen(new_string, old_string, 1);
                        if std::fs::write(&full_path, reverted).is_err() {
                            failures.push(format!("Edit restore failed: {}", path));
                        } else {
                            debug!(path = %path, "Edit reverted");
                        }
                    }
                    Err(e) => {
                        failures.push(format!("Edit restore skipped (read failed): {} ({})", path, e));
                    }
                }
            }
            FileChange::Write { path, .. } => {
                let full_path = Path::new(cwd).join(path);
                // 删除文件
                if full_path.exists() {
                    if std::fs::remove_file(&full_path).is_err() {
                        failures.push(format!("Write restore failed (delete): {}", path));
                    } else {
                        debug!(path = %path, "Write file deleted");
                    }
                }
                // 尝试 git restore
                let _ = try_git_restore(path, cwd);
            }
        }
    }

    failures
}

/// 尝试通过 git 恢复文件到 HEAD 状态。
fn try_git_restore(path: &str, cwd: &str) -> Result<(), String> {
    use std::process::Command;
    let output = Command::new("git")
        .args(["checkout", "HEAD", "--", path])
        .current_dir(cwd)
        .output()
        .map_err(|e| e.to_string())?;

    if output.status.success() {
        debug!(path = %path, "Git restored");
        Ok(())
    } else {
        let stderr = String::from_utf8_lossy(&output.stderr);
        // 文件不在 git 追踪中是正常的，不报错
        if stderr.contains("did not match any file") {
            debug!(path = %path, "File not in git, skip restore");
            Ok(())
        } else {
            Err(stderr.to_string())
        }
    }
}

/// 检查 ToolUse/ToolResult 配对完整性。
///
/// 删除后，剩余消息中不能有孤儿 ToolUse（无对应 ToolResult）。
fn validate_tool_pairing(messages: &[BaseMessage]) -> bool {
    // 收集所有 tool_use_id
    let mut tool_use_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
    let mut tool_result_ids: std::collections::HashSet<String> = std::collections::HashSet::new();

    for msg in messages {
        match msg {
            BaseMessage::Ai { tool_calls, content, .. } => {
                for tc in tool_calls {
                    tool_use_ids.insert(tc.id.clone());
                }
                if tool_calls.is_empty() {
                    for block in content.content_blocks() {
                        if let ContentBlock::ToolUse { id, .. } = block {
                            tool_use_ids.insert(id.clone());
                        }
                    }
                }
            }
            BaseMessage::Tool { tool_call_id, .. } => {
                tool_result_ids.insert(tool_call_id.clone());
            }
            _ => {}
        }
    }

    // 每个 tool_use 必须有对应的 tool_result
    for id in &tool_use_ids {
        if !tool_result_ids.contains(id) {
            warn!(tool_use_id = %id, "Orphan ToolUse found after rewind");
            return false;
        }
    }
    true
}

/// Rewind 命令参数（从 TUI 侧通过 args JSON 传递）。
#[derive(serde::Deserialize)]
struct RewindArgs {
    /// 目标消息 ID：回退到此消息之前（即保留此消息及之前的所有消息）
    target_message_id: String,
    /// 是否回退文件变更
    revert_files: bool,
}

#[async_trait::async_trait]
impl AgentCommand for RewindCommand {
    fn name(&self) -> &str {
        Self::NAME
    }

    fn aliases(&self) -> Vec<&str> {
        vec!["undo"]
    }

    fn description(&self) -> &str {
        "回退对话到指定消息节点"
    }

    fn kind(&self) -> CommandKind {
        CommandKind::Immediate
    }

    async fn execute(&self, ctx: CommandContext) -> CommandResult {
        // 解析参数
        let args: RewindArgs = match serde_json::from_str(&ctx.args) {
            Ok(a) => a,
            Err(e) => {
                let _ = ctx.event_sink.push_event(
                    &ctx.session_id,
                    &ExecutorEvent::CompactError {
                        message: format!("rewind 参数解析失败: {}", e),
                    },
                    0,
                ).await;
                return CommandResult {
                    messages: ctx.history,
                    stop_reason: PromptStopReason::EndTurn,
                };
            }
        };

        // 定位目标消息在 history 中的索引
        let target_idx = ctx.history.iter().position(|m| {
            m.id().as_uuid().to_string() == args.target_message_id
        });

        let target_idx = match target_idx {
            Some(idx) => idx,
            None => {
                let _ = ctx.event_sink.push_event(
                    &ctx.session_id,
                    &ExecutorEvent::CompactError {
                        message: "rewind: 未找到目标消息".to_string(),
                    },
                    0,
                ).await;
                return CommandResult {
                    messages: ctx.history,
                    stop_reason: PromptStopReason::EndTurn,
                };
            }
        };

        // 截断：保留 [0..target_idx) 的消息（不包含 target_message_id 本身）
        // target_message_id 是用户选择的那条 UserBubble，回退意味着移除该条及之后的所有消息
        let removed_messages: Vec<BaseMessage> = ctx.history[target_idx..].to_vec();
        let mut retained_messages: Vec<BaseMessage> = ctx.history[..target_idx].to_vec();

        // 如果回退文件
        let mut revert_failures = Vec::new();
        if args.revert_files {
            let changes = extract_file_changes(&removed_messages);
            debug!(
                changes_count = changes.len(),
                "Extracted file changes to revert"
            );
            revert_failures = revert_file_changes(&changes, &ctx.cwd);
        }

        // 安全性检查：ToolUse/ToolResult 配对
        if !validate_tool_pairing(&retained_messages) {
            warn!("ToolUse/ToolResult pairing check failed after rewind");
            // 仍然继续，但记录警告
        }

        // 持久化删除
        if !removed_messages.is_empty() {
            let removed_ids: Vec<MessageId> =
                removed_messages.iter().map(|m| m.id()).collect();
            // 注意：CommandContext 当前没有 thread_store 和 thread_id
            // 这里需要扩展 CommandContext 或通过其他方式获取
            // 暂时通过删除消息 ID 列表在返回结果中传递，由 executor 层处理持久化
            debug!(
                count = removed_ids.len(),
                "Messages to be deleted from persistence"
            );
        }

        // 构建摘要通知
        let mut summary_parts = vec![format!(
            "已回退 {} 条消息",
            removed_messages.len()
        )];
        if args.revert_files {
            if revert_failures.is_empty() {
                summary_parts.push("文件变更已全部恢复".to_string());
            } else {
                summary_parts.push(format!(
                    "部分文件恢复失败: {}",
                    revert_failures.join("; ")
                ));
            }
        }

        // 发送 CompactCompleted 事件通知 TUI 更新
        let _ = ctx.event_sink.push_event(
            &ctx.session_id,
            &ExecutorEvent::CompactCompleted {
                summary: summary_parts.join("\n"),
                files: vec![],
                skills: vec![],
                micro_cleared: 0,
                messages: retained_messages.clone(),
            },
            0,
        ).await;

        CommandResult {
            messages: retained_messages,
            stop_reason: PromptStopReason::EndTurn,
        }
    }
}
```

- [ ] **Step 2: 在 CommandContext 中新增 thread_store 和 thread_id**

修改 `peri-acp/src/session/command/mod.rs` 的 `CommandContext`：

```rust
pub struct CommandContext {
    pub session_id: String,
    pub history: Vec<BaseMessage>,
    pub cwd: String,
    pub peri_config: Arc<PeriConfig>,
    pub compact_model: Option<Arc<dyn BaseModel>>,
    pub event_sink: Arc<dyn EventSink>,
    pub args: String,
    pub cancel_token: AgentCancellationToken,
    /// Thread store for persistent operations (rewind message deletion).
    pub thread_store: Option<Arc<dyn peri_agent::thread::ThreadStore>>,
    /// Current thread ID for persistent operations.
    pub thread_id: Option<String>,
}
```

修改 `peri-acp/src/session/executor.rs` 中构造 CommandContext 的位置（约第 179 行）：

```rust
let ctx = crate::session::command::CommandContext {
    session_id: session_id.clone(),
    history: history.clone(),
    cwd: cwd.to_string(),
    peri_config: Arc::new(peri_config.as_ref().clone()),
    compact_model: compact_model.clone(),
    event_sink: event_sink.clone(),
    args: args.to_string(),
    cancel_token: cancel.clone(),
    thread_store: thread_store.clone(),
    thread_id: thread_id.clone(),
};
```

- [ ] **Step 3: 在 rewind 命令中使用 thread_store 删除消息**

在 `rewind.rs` 的 `execute` 方法中，`// 持久化删除` 注释后补充实际删除逻辑：

```rust
        // 持久化删除
        if !removed_messages.is_empty() {
            let removed_ids: Vec<MessageId> =
                removed_messages.iter().map(|m| m.id()).collect();
            if let (Some(store), Some(tid)) = (&ctx.thread_store, &ctx.thread_id) {
                use peri_agent::thread::ThreadId;
                if let Ok(thread_id) = ThreadId::from_str(tid) {
                    if let Err(e) = store.delete_messages(&thread_id, &removed_ids).await {
                        warn!(error = %e, "Failed to delete messages from persistence");
                    }
                }
            }
        }
```

- [ ] **Step 4: 注册 rewind 命令**

修改 `peri-acp/src/session/command/mod.rs`：

```rust
pub mod clear;
pub mod compact;
pub mod rewind;
```

在 `default_command_registry()` 中注册：

```rust
pub fn default_command_registry() -> CommandRegistry {
    let mut reg = CommandRegistry::new();
    reg.register(Box::new(compact::CompactCommand));
    reg.register(Box::new(clear::ClearCommand));
    reg.register(Box::new(rewind::RewindCommand));
    reg
}
```

- [ ] **Step 5: 编译验证**

Run: `cargo build -p peri-acp`
Expected: 编译成功

- [ ] **Step 6: Commit**

```bash
git add peri-acp/src/session/command/rewind.rs peri-acp/src/session/command/mod.rs peri-acp/src/session/executor.rs
git commit -m "feat(acp): add /rewind immediate command for conversation rollback"
```

---

### Task 3: TUI 侧 `RewindPrompt` 数据结构

**Files:**
- Create: `peri-tui/src/app/rewind_prompt.rs`
- Create: `peri-tui/src/app/rewind_prompt_test.rs`
- Modify: `peri-tui/src/app/interaction.rs`

- [ ] **Step 1: 创建 RewindPrompt 结构体**

创建 `peri-tui/src/app/rewind_prompt.rs`：

```rust
//! Rewind 弹窗数据结构。
//!
//! 用户选择回退节点的弹窗状态，包括消息列表、光标位置和操作模式。

/// Rewind 弹窗中的用户消息条目。
#[derive(Debug, Clone)]
pub struct RewindItem {
    /// 消息 ID（BaseMessage.id()，用于传给 /rewind 命令）
    pub message_id: String,
    /// 截断摘要文本（用于显示）
    pub summary: String,
    /// 该消息之后（含自身）的消息数量（用于显示影响范围）
    pub message_count_after: usize,
    /// 该消息之后的文件变更列表（路径 + 操作类型）
    pub file_changes: Vec<FileChangeInfo>,
}

/// 文件变更信息（用于二次确认弹窗显示）。
#[derive(Debug, Clone)]
pub struct FileChangeInfo {
    pub path: String,
    /// "Write" 或 "Edit"
    pub operation: String,
}

/// Rewind 操作模式。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RewindMode {
    /// 仅回退消息
    MessagesOnly,
    /// 回退消息 + 文件
    MessagesAndFiles,
    /// 二次确认阶段（显示文件列表，等 Enter 确认）
    ConfirmRevert,
}

/// Rewind 弹窗状态。
pub struct RewindPrompt {
    /// 可回退的用户消息列表
    pub items: Vec<RewindItem>,
    /// 当前光标位置
    pub cursor: usize,
    /// 当前操作模式
    pub mode: RewindMode,
}
```

- [ ] **Step 2: 修改 InteractionPrompt 枚举**

修改 `peri-tui/src/app/interaction.rs`：

```rust
use super::{ask_user_prompt::AskUserBatchPrompt, hitl_prompt::HitlBatchPrompt, rewind_prompt::RewindPrompt};

/// 统一交互弹窗枚举：同一时刻只允许一种弹窗激活
pub enum InteractionPrompt {
    Approval(HitlBatchPrompt),
    Questions(AskUserBatchPrompt),
    Rewind(RewindPrompt),
}
```

- [ ] **Step 3: 编译验证**

Run: `cargo build -p peri-tui`
Expected: 可能有引用 InteractionPrompt 的地方需要更新 match 分支，编译错误会指出需要更新的位置（popups.rs, mod.rs, status_bar.rs 等）。此时只需在这些 match 中添加 `InteractionPrompt::Rewind(_)` 分支（暂时为空 `=> {}`），后续 Task 会填充。

- [ ] **Step 4: Commit**

```bash
git add peri-tui/src/app/rewind_prompt.rs peri-tui/src/app/interaction.rs
git commit -m "feat(tui): add RewindPrompt data structure and InteractionPrompt variant"
```

---

### Task 4: TUI 侧 ESC 双击检测 + Rewind 弹窗触发

**Files:**
- Modify: `peri-tui/src/app/global_ui_state.rs`
- Modify: `peri-tui/src/event/keyboard/normal_keys.rs`
- Create: `peri-tui/src/app/agent_ops/rewind.rs`
- Modify: `peri-tui/src/app/mod.rs` (或对应 `.inc` 文件，注册 agent_ops::rewind 模块)

- [ ] **Step 1: 在 GlobalUiState 中添加 rewind_pending_since**

修改 `peri-tui/src/app/global_ui_state.rs`：

```rust
pub struct GlobalUiState {
    // ... 现有字段 ...
    pub quit_pending_since: Option<Instant>,
    /// 双击 ESC 检测时间戳（rewind 弹窗触发）
    pub rewind_pending_since: Option<Instant>,
    // ...
}
```

在 `new()` 中初始化：

```rust
    pub fn new() -> Self {
        Self {
            // ... 现有字段 ...
            rewind_pending_since: None,
            // ...
        }
    }
```

- [ ] **Step 2: 创建 rewind ops 模块**

创建 `peri-tui/src/app/agent_ops/rewind.rs`：

```rust
//! Rewind 弹窗操作：打开、确认、取消。

use crate::app::{
    rewind_prompt::{FileChangeInfo, RewindItem, RewindMode, RewindPrompt},
    App, InteractionPrompt, MessageViewModel,
};
use peri_agent::messages::{BaseMessage, ContentBlock};

impl App {
    /// 打开 rewind 选择器弹窗。
    ///
    /// 遍历当前会话的消息历史，提取所有 UserBubble 作为可选的回退节点。
    /// 同时分析每个节点之后的文件变更信息。
    pub(crate) fn open_rewind_prompt(&mut self) {
        let origin_messages = &self.session_mgr.current().agent.origin_messages;

        if origin_messages.is_empty() {
            // 没有历史，无法回退
            self.session_mgr
                .current_mut()
                .messages
                .push_system_note("没有可回退的对话轮次".to_string());
            return;
        }

        // 收集所有 Human 消息的位置和内容
        let mut items: Vec<RewindItem> = Vec::new();
        let human_indices: Vec<usize> = origin_messages
            .iter()
            .enumerate()
            .filter(|(_, m)| matches!(m, BaseMessage::Human { .. }))
            .map(|(i, _)| i)
            .collect();

        for (idx, &human_idx) in human_indices.iter().enumerate() {
            let msg = &origin_messages[human_idx];
            let text = msg.content();
            let summary: String = text.chars().take(60).collect();
            let count_after = origin_messages.len() - human_idx;

            // 分析该消息之后的文件变更
            let file_changes = extract_file_changes_from_messages(
                &origin_messages[human_idx..],
            );

            items.push(RewindItem {
                message_id: msg.id().as_uuid().to_string(),
                summary,
                message_count_after: count_after,
                file_changes,
            });
        }

        if items.is_empty() {
            self.session_mgr
                .current_mut()
                .messages
                .push_system_note("没有可回退的对话轮次".to_string());
            return;
        }

        // 默认选中最后一个
        let cursor = items.len().saturating_sub(1);

        self.session_mgr.current_mut().agent.interaction_prompt =
            Some(InteractionPrompt::Rewind(RewindPrompt {
                items,
                cursor,
                mode: RewindMode::MessagesOnly,
            }));
    }

    /// 关闭 rewind 弹窗（Esc 取消）。
    pub(crate) fn cancel_rewind(&mut self) {
        self.session_mgr.current_mut().agent.interaction_prompt = None;
    }

    /// Rewind 弹窗光标上移。
    pub(crate) fn rewind_cursor_up(&mut self) {
        if let Some(InteractionPrompt::Rewind(prompt)) =
            &mut self.session_mgr.current_mut().agent.interaction_prompt
        {
            if prompt.cursor > 0 {
                prompt.cursor -= 1;
            }
        }
    }

    /// Rewind 弹窗光标下移。
    pub(crate) fn rewind_cursor_down(&mut self) {
        if let Some(InteractionPrompt::Rewind(prompt)) =
            &mut self.session_mgr.current_mut().agent.interaction_prompt
        {
            if prompt.cursor < prompt.items.len().saturating_sub(1) {
                prompt.cursor += 1;
            }
        }
    }

    /// 切换到"消息 + 文件回退"模式。
    pub(crate) fn rewind_toggle_files(&mut self) {
        if let Some(InteractionPrompt::Rewind(prompt)) =
            &mut self.session_mgr.current_mut().agent.interaction_prompt
        {
            prompt.mode = match prompt.mode {
                RewindMode::MessagesOnly => RewindMode::MessagesAndFiles,
                RewindMode::MessagesAndFiles => RewindMode::MessagesOnly,
                RewindMode::ConfirmRevert => RewindMode::MessagesAndFiles,
            };
        }
    }

    /// 确认 rewind 操作。
    ///
    /// 如果模式是 MessagesAndFiles，先进入二次确认。
    /// 如果模式是 ConfirmRevert 或 MessagesOnly，发送 /rewind 命令。
    pub(crate) fn rewind_confirm(&mut self) {
        let (target_id, revert_files, go) = {
            let session = self.session_mgr.current();
            if let Some(InteractionPrompt::Rewind(prompt)) = &session.agent.interaction_prompt {
                let item = &prompt.items[prompt.cursor];
                match prompt.mode {
                    RewindMode::MessagesAndFiles => {
                        // 进入二次确认
                        (item.message_id.clone(), true, false)
                    }
                    RewindMode::ConfirmRevert => {
                        // 二次确认通过，执行
                        (item.message_id.clone(), true, true)
                    }
                    RewindMode::MessagesOnly => {
                        (item.message_id.clone(), false, true)
                    }
                }
            } else {
                return;
            }
        };

        if !go {
            // 进入二次确认
            if let Some(InteractionPrompt::Rewind(prompt)) =
                &mut self.session_mgr.current_mut().agent.interaction_prompt
            {
                prompt.mode = RewindMode::ConfirmRevert;
            }
            return;
        }

        // 关闭弹窗
        self.session_mgr.current_mut().agent.interaction_prompt = None;

        // 发送 /rewind 命令
        let args = serde_json::json!({
            "target_message_id": target_id,
            "revert_files": revert_files,
        })
        .to_string();

        self.submit_message(format!("/rewind {}", args));
    }

    /// 运行中按 ESC 的提示（Status Bar 显示）。
    pub(crate) fn show_rewind_busy_hint(&mut self) {
        // 设置 Status Bar 提示：通过在 global_ui 中添加瞬时提示字段
        // 这里的实现需要配合 status_bar 的提示逻辑
    }
}

/// 从消息段中提取文件变更信息。
fn extract_file_changes_from_messages(messages: &[BaseMessage]) -> Vec<FileChangeInfo> {
    let mut changes = Vec::new();
    let mut seen_paths = std::collections::HashSet::new();

    for msg in messages {
        match msg {
            BaseMessage::Ai { tool_calls, content, .. } => {
                for tc in tool_calls {
                    if tc.name == "Write" || tc.name == "Edit" {
                        if let Some(path) = tc.arguments.get("file_path").and_then(|v| v.as_str()) {
                            if seen_paths.insert(path.to_string()) {
                                changes.push(FileChangeInfo {
                                    path: path.to_string(),
                                    operation: tc.name.clone(),
                                });
                            }
                        }
                    }
                }
                if tool_calls.is_empty() {
                    for block in content.content_blocks() {
                        if let ContentBlock::ToolUse { name, input, .. } = block {
                            if name == "Write" || name == "Edit" {
                                if let Some(path) = input.get("file_path").and_then(|v| v.as_str()) {
                                    if seen_paths.insert(path.to_string()) {
                                        changes.push(FileChangeInfo {
                                            path: path.to_string(),
                                            operation: name.clone(),
                                        });
                                    }
                                }
                            }
                        }
                    }
                }
            }
            _ => {}
        }
    }

    changes
}
```

注意：`self.submit_message(format!("/rewind {}", args))` 的调用需要确认 `submit_message` 的签名。实际可能是通过 ACP client 的 `prompt()` 方法发送。参考 `agent_submit.rs` 中的提交流程，可能需要：

```rust
// 通过 ACP client 发送
if let Some(client) = &self.session_mgr.current().agent.acp_client {
    let _ = client.prompt(format!("/rewind {}", args)).await;
}
```

由于 `App` 的方法不是 async，可能需要同步封装或通过 channel 发送。具体实现时参考 `/compact` 命令的发送方式（直接提交文本 `/compact` 即可）。

- [ ] **Step 3: 在 normal_keys.rs 中添加 ESC 双击检测**

修改 `peri-tui/src/event/keyboard/normal_keys.rs`。

在现有 ESC 处理分支之后（`Input { key: Key::Esc, .. } if app.session_mgr.current_mut().ui.at_mention.active` 之后），添加新的 ESC 匹配分支：

```rust
        // Esc: 双击触发 rewind 选择器（仅空闲时）
        Input { key: Key::Esc, .. } if !app.session_mgr.current().ui.loading => {
            if let Some(since) = app.global_ui.rewind_pending_since {
                if since.elapsed() < std::time::Duration::from_secs(2) {
                    // 双击 ESC → 打开 rewind 选择器
                    app.global_ui.rewind_pending_since = None;
                    app.open_rewind_prompt();
                } else {
                    app.global_ui.rewind_pending_since = Some(std::time::Instant::now());
                }
            } else {
                app.global_ui.rewind_pending_since = Some(std::time::Instant::now());
            }
        }
        // Esc: 运行中 → Status Bar 提示
        Input { key: Key::Esc, .. } if app.session_mgr.current().ui.loading => {
            // 已有清空 pending_messages 的逻辑，这里不需要额外处理
            // rewind 运行中提示由 Status Bar 自动判断
        }
```

同时在 `_ =>` 分支中取消 rewind_pending：

```rust
        _ => {
            // Any other key cancels quit-pending and rewind-pending state
            app.global_ui.quit_pending_since = None;
            app.global_ui.rewind_pending_since = None;
        }
```

- [ ] **Step 4: 编译验证**

Run: `cargo build -p peri-tui`
Expected: 可能需要添加模块声明。在 `agent_ops` 的 `.inc` 文件中添加 `pub(super) mod rewind;`。

- [ ] **Step 5: Commit**

```bash
git add peri-tui/src/app/global_ui_state.rs peri-tui/src/event/keyboard/normal_keys.rs peri-tui/src/app/agent_ops/rewind.rs
git commit -m "feat(tui): add double-ESC rewind trigger and prompt logic"
```

---

### Task 5: Rewind 弹窗渲染

**Files:**
- Create: `peri-tui/src/ui/main_ui/popups/rewind.rs`
- Create: `peri-tui/src/ui/main_ui/popups/rewind_confirm.rs`
- Modify: `peri-tui/src/ui/main_ui/popups/mod.rs`
- Modify: `peri-tui/src/ui/main_ui/mod.rs`
- Modify: `peri-tui/src/ui/main_ui/status_bar.rs`

- [ ] **Step 1: 创建 rewind 选择器渲染**

创建 `peri-tui/src/ui/main_ui/popups/rewind.rs`：

```rust
//! Rewind 消息选择器弹窗渲染。

use ratatui::{
    layout::Rect,
    style::{Modifier, Style},
    text::{Line, Span, Text},
    widgets::Paragraph,
    Frame,
};

use peri_widgets::BorderedPanel;

use crate::{
    app::{rewind_prompt::RewindMode, App, InteractionPrompt},
    ui::theme,
};

/// 渲染 rewind 消息选择器弹窗。
pub(crate) fn render_rewind_popup(f: &mut Frame, app: &App, area: Rect) {
    let Some(InteractionPrompt::Rewind(prompt)) =
        &app.session_mgr.current().agent.interaction_prompt
    else {
        return;
    };

    let lc = &app.services.lc;
    let inner = BorderedPanel::new(Span::styled(
        "Rewind",
        Style::default()
            .fg(theme::ACCENT)
            .add_modifier(Modifier::BOLD),
    ))
    .border_style(Style::default().fg(theme::ACCENT))
    .render(f, area);

    let max_width = inner.width as usize;
    let mut lines: Vec<Line> = Vec::new();

    // 消息列表
    for (i, item) in prompt.items.iter().enumerate() {
        let is_cursor = i == prompt.cursor;

        let cursor_indicator = if is_cursor { "❯ " } else { "  " };

        // 截断摘要
        let summary = if item.summary.chars().count() > max_width.saturating_sub(8) {
            format!(
                "{}…",
                item.summary
                    .chars()
                    .take(max_width.saturating_sub(8))
                    .collect::<String>()
            )
        } else {
            item.summary.clone()
        };

        // 消息数量标签
        let count_label = format!("({}msg)", item.message_count_after);

        lines.push(Line::from(vec![
            Span::styled(
                cursor_indicator,
                if is_cursor {
                    Style::default()
                        .fg(theme::ACCENT)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default()
                },
            ),
            Span::styled(
                summary,
                if is_cursor {
                    Style::default()
                        .fg(theme::TEXT)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(theme::MUTED)
                },
            ),
            Span::styled(
                format!(" {}", count_label),
                Style::default().fg(theme::MUTED),
            ),
        ]));
    }

    // 底部分割线 + 操作模式指示
    lines.push(Line::from(""));
    let mode_label = match prompt.mode {
        RewindMode::MessagesOnly => "1. Rewind only",
        RewindMode::MessagesAndFiles => "2. Rewind + Revert files",
        RewindMode::ConfirmRevert => "⚠ Confirm: Revert files?",
    };

    lines.push(Line::from(vec![
        Span::styled(
            " Tab: ",
            Style::default().fg(theme::MUTED).add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            mode_label,
            Style::default().fg(theme::ACCENT),
        ),
    ]));

    let para = Paragraph::new(Text::from(lines));
    f.render_widget(para, inner);
}
```

- [ ] **Step 2: 创建 rewind 二次确认渲染**

创建 `peri-tui/src/ui/main_ui/popups/rewind_confirm.rs`：

```rust
//! Rewind 二次确认渲染（嵌入到 rewind 弹窗底部）。
//!
//! 当用户选择 "Rewind + Revert files" 并按 Enter 后，
//! 弹窗切换到确认模式，显示受影响文件列表。

// 注意：二次确认直接在 rewind.rs 的 render_rewind_popup 中处理，
// 当 prompt.mode == ConfirmRevert 时显示文件列表和确认提示。
// 不需要独立文件，在 rewind.rs 中扩展即可。
```

实际上二次确认的渲染直接在 `rewind.rs` 中处理。在 `render_rewind_popup` 的消息列表之后添加：

```rust
    // 如果是二次确认模式，显示文件列表
    if prompt.mode == RewindMode::ConfirmRevert {
        let selected = &prompt.items[prompt.cursor];
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "Files to be reverted:",
            Style::default()
                .fg(theme::WARNING)
                .add_modifier(Modifier::BOLD),
        )));

        for fc in &selected.file_changes {
            let op_label = match fc.operation.as_str() {
                "Write" => "Write → Delete+Git restore",
                "Edit" => "Edit → Revert",
                _ => &fc.operation,
            };
            lines.push(Line::from(vec![
                Span::styled("  • ", Style::default().fg(theme::MUTED)),
                Span::styled(
                    fc.path.chars().take(max_width.saturating_sub(20)).collect::<String>(),
                    Style::default().fg(theme::TEXT),
                ),
                Span::styled(
                    format!(" ({})", op_label),
                    Style::default().fg(theme::MUTED),
                ),
            ]));
        }

        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "Enter to confirm, Esc to cancel",
            Style::default().fg(theme::WARNING),
        )));
    }
```

- [ ] **Step 3: 注册模块和渲染分发**

修改 `peri-tui/src/ui/main_ui/popups/mod.rs`：

```rust
pub mod ask_user;
pub(crate) mod ask_user_height;
pub mod hints;
pub mod hitl;
pub mod oauth;
pub mod rewind;
pub mod setup_wizard;
```

修改 `peri-tui/src/ui/main_ui/mod.rs` 的弹窗渲染分发（约第 104 行）：

```rust
        match &app.session_mgr.current_mut().agent.interaction_prompt {
            Some(crate::app::InteractionPrompt::Approval(_)) => {
                popups::hitl::render_hitl_popup(f, app, panel_area);
            }
            Some(crate::app::InteractionPrompt::Questions(_)) => {
                popups::ask_user::render_ask_user_popup(f, app, panel_area);
            }
            Some(crate::app::InteractionPrompt::Rewind(_)) => {
                popups::rewind::render_rewind_popup(f, app, panel_area);
            }
            None => {}
        }
```

- [ ] **Step 4: 弹窗高度计算**

在 `peri-tui/src/ui/main_ui/mod.rs` 的高度计算 match 中添加：

```rust
    } else if let Some(crate::app::InteractionPrompt::Rewind(p)) =
        &app.session_mgr.current().agent.interaction_prompt
    {
        // 列表项 + 分割线 + 模式指示 + (二次确认文件列表)
        let base = (p.items.len() as u16 * 1 + 3);
        let confirm_extra = if p.mode == crate::app::rewind_prompt::RewindMode::ConfirmRevert {
            let selected = &p.items[p.cursor];
            (selected.file_changes.len() as u16 + 3).min(10)
        } else {
            0
        };
        (base + confirm_extra).max(5)
    }
```

- [ ] **Step 5: Status Bar 快捷键提示**

修改 `peri-tui/src/ui/main_ui/status_bar.rs` 的 `render_second_row` 中的 match 分支：

在 `Some(crate::app::InteractionPrompt::Approval(_))` 之后添加：

```rust
        Some(crate::app::InteractionPrompt::Rewind(prompt)) => {
            use crate::app::rewind_prompt::RewindMode;
            match prompt.mode {
                RewindMode::ConfirmRevert => {
                    format_hints(&[
                        ("Enter".to_string(), lc.tr("key-confirm")),
                        ("Esc".to_string(), lc.tr("key-cancel")),
                    ], key_style, desc_style)
                }
                _ => {
                    format_hints(&[
                        ("↑↓".to_string(), lc.tr("key-move")),
                        ("Tab".to_string(), lc.tr("key-rewind-toggle-files")),
                        ("Enter".to_string(), lc.tr("key-confirm")),
                        ("Esc".to_string(), lc.tr("key-cancel")),
                    ], key_style, desc_style)
                }
            }
        }
```

注意：`key-rewind-toggle-files` 是新增的 i18n key，需要在 `peri-tui/src/i18n/locales/` 的 ftl 文件中添加。最简方案是在 status_bar.rs 中硬编码中文：

```rust
                    format_hints(&[
                        ("↑↓".to_string(), "移动".to_string()),
                        ("Tab".to_string(), "切换回退文件".to_string()),
                        ("Enter".to_string(), lc.tr("key-confirm")),
                        ("Esc".to_string(), lc.tr("key-cancel")),
                    ], key_style, desc_style)
```

- [ ] **Step 6: 编译验证**

Run: `cargo build -p peri-tui`
Expected: 编译成功

- [ ] **Step 7: Commit**

```bash
git add peri-tui/src/ui/main_ui/popups/rewind.rs peri-tui/src/ui/main_ui/popups/mod.rs peri-tui/src/ui/main_ui/mod.rs peri-tui/src/ui/main_ui/status_bar.rs
git commit -m "feat(tui): add rewind popup rendering and status bar hints"
```

---

### Task 6: Rewind 弹窗按键处理

**Files:**
- Modify: `peri-tui/src/event/keyboard/popups.rs`

- [ ] **Step 1: 在 popups.rs 中添加 Rewind 按键处理**

在 `handle_popups` 函数中，HITL 处理之前或之后，添加 Rewind 弹窗处理：

```rust
    // Rewind popup
    if matches!(
        &app.session_mgr.current_mut().agent.interaction_prompt,
        Some(crate::app::InteractionPrompt::Rewind(_))
    ) {
        match input {
            Input { key: Key::Up, .. } => {
                app.rewind_cursor_up();
                return Some(Action::Redraw);
            }
            Input { key: Key::Down, .. } => {
                app.rewind_cursor_down();
                return Some(Action::Redraw);
            }
            Input { key: Key::Tab, .. } => {
                app.rewind_toggle_files();
                return Some(Action::Redraw);
            }
            Input { key: Key::Enter, .. } => {
                app.rewind_confirm();
                return Some(Action::Redraw);
            }
            Input { key: Key::Esc, .. } => {
                app.cancel_rewind();
                return Some(Action::Redraw);
            }
            _ => return Some(Action::Redraw),
        }
    }
```

- [ ] **Step 2: 编译验证**

Run: `cargo build -p peri-tui`
Expected: 编译成功

- [ ] **Step 3: Commit**

```bash
git add peri-tui/src/event/keyboard/popups.rs
git commit -m "feat(tui): add rewind popup keyboard handling"
```

---

### Task 7: 运行中 ESC 提示（Status Bar）

**Files:**
- Modify: `peri-tui/src/app/global_ui_state.rs`
- Modify: `peri-tui/src/ui/main_ui/status_bar.rs`

- [ ] **Step 1: 在 GlobalUiState 添加 rewind_busy_hint_until**

```rust
pub struct GlobalUiState {
    // ... 现有字段 ...
    /// 运行中按 ESC 的 rewind 提示截止时间
    pub rewind_busy_hint_until: Option<Instant>,
}
```

在 `new()` 中初始化 `rewind_busy_hint_until: None`。

- [ ] **Step 2: 在 ESC 运行中分支设置提示**

修改 `normal_keys.rs` 中运行中的 ESC 分支：

```rust
        Input { key: Key::Esc, .. } if app.session_mgr.current().ui.loading => {
            // 原有清空 pending_messages 逻辑不变
            // ...

            // 设置 rewind 不可用提示（Status Bar 第二行，3秒后消失）
            app.global_ui.rewind_busy_hint_until =
                Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
        }
```

- [ ] **Step 3: 在 Status Bar 第二行渲染提示**

修改 `status_bar.rs` 的 `render_second_row` 中左侧 `left_spans` 构建逻辑，在现有的瞬时提示检查之后添加：

```rust
    // Rewind 忙碌提示
    if let Some(until) = app.global_ui.rewind_busy_hint_until {
        if Instant::now() < until {
            left_spans.push(Span::styled(
                " Agent 运行中，请等待后再撤销 ",
                Style::default().fg(theme::WARNING),
            ));
            has_content = true;
        }
    }
```

- [ ] **Step 4: 编译验证**

Run: `cargo build -p peri-tui`
Expected: 编译成功

- [ ] **Step 5: Commit**

```bash
git add peri-tui/src/app/global_ui_state.rs peri-tui/src/event/keyboard/normal_keys.rs peri-tui/src/ui/main_ui/status_bar.rs
git commit -m "feat(tui): add rewind busy hint in status bar when agent is running"
```

---

### Task 8: 集成测试 + 边界处理

**Files:**
- Modify: 各文件的边界条件处理

- [ ] **Step 1: 确保 compact 清理路径兼容 rewind**

验证 `handle_compact_completed` 能正确处理 rewind 发送的 `CompactCompleted` 事件。rewind 命令发送的 `CompactCompleted` 中 `micro_cleared = 0`、`files = vec![]`、`skills = vec![]`，会走到 full compact 分支。需要确认：

1. `compact_manual` 标志：rewind 不是手动 compact，需要确保 `compact_manual` 为 false。检查 `handle_compact_started` 是否会被触发——不会，因为 rewind 直接发送 `CompactCompleted`，不发送 `CompactStarted`。所以 `is_manual` 为 false，不会执行 `set_loading(false)`。这是正确的，因为 rewind 时 TUI 没有设置 loading。

2. 如果 rewind 在空闲状态执行（TUI 未设置 loading），不需要额外处理。

- [ ] **Step 2: 空历史/单条消息边界处理**

验证以下场景：
- 空对话时双击 ESC → `open_rewind_prompt()` 显示 "没有可回退的对话轮次"
- 只有一条 Human 消息（AI 还没回复）时双击 ESC → 列表显示该条消息，回退后清空

- [ ] **Step 3: 全量编译**

Run: `cargo build`
Expected: 编译成功

- [ ] **Step 4: 运行全量测试**

Run: `cargo test`
Expected: 所有测试通过

- [ ] **Step 5: Commit**

```bash
git add -A
git commit -m "feat: complete rewind feature with edge case handling"
```

---

## Self-Review

### 1. Spec Coverage

| 需求 | 对应 Task |
|------|----------|
| 双击 ESC 弹出选择器 | Task 4 |
| 列出所有 UserBubble（截断摘要） | Task 3 + Task 5 |
| 默认选中最后一条 | Task 3 (open_rewind_prompt) |
| 底部选项 Rewind only / Rewind+Files | Task 5 (Tab 切换) |
| 二次确认（文件列表+操作类型） | Task 5 (ConfirmRevert 模式) |
| 文件回退（Edit swap, Write 删+git） | Task 2 |
| 尽力恢复模式 | Task 2 (revert_file_changes) |
| 按具体 message_id 删除持久化 | Task 1 + Task 2 |
| ToolUse/ToolResult 配对检查 | Task 2 (validate_tool_pairing) |
| 运行中 ESC → Status Bar 提示 | Task 7 |
| CompactCompleted 复用 | Task 2 |
| ACP Server 侧 /rewind 命令 | Task 2 |

### 2. Placeholder Scan

无 TBD/TODO/placeholder。

### 3. Type Consistency

- `RewindPrompt` 在 `rewind_prompt.rs` 定义，在 `interaction.rs` 引用，在 `popups.rs`/`mod.rs` 匹配——类型一致。
- `FileChangeInfo` 在 `rewind_prompt.rs` 定义，在 `agent_ops/rewind.rs` 和 `popups/rewind.rs` 使用——一致。
- `CommandContext` 新增的 `thread_store`/`thread_id` 在 `mod.rs` 定义，在 `executor.rs` 构造，在 `rewind.rs` 使用——一致。
- `MessageId` 使用 `peri_agent::messages::MessageId`——全链路一致。
