From 39ade9d52bd69d45c1ed6ca2f71bf14a0cd142b3 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Wed, 13 Aug 2025 16:48:49 -0700 Subject: [PATCH 01/31] (in progress) Implement checkpointing using git CLI commands --- crates/chat-cli/src/cli/chat/cli/mod.rs | 6 ++ crates/chat-cli/src/cli/chat/consts.rs | 3 + crates/chat-cli/src/cli/chat/conversation.rs | 9 ++ crates/chat-cli/src/cli/chat/mod.rs | 100 +++++++++++++++++- .../chat-cli/src/cli/chat/tools/fs_write.rs | 2 +- crates/chat-cli/src/cli/chat/tools/mod.rs | 9 ++ 6 files changed, 126 insertions(+), 3 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/mod.rs b/crates/chat-cli/src/cli/chat/cli/mod.rs index 4805426d06..643d72023a 100644 --- a/crates/chat-cli/src/cli/chat/cli/mod.rs +++ b/crates/chat-cli/src/cli/chat/cli/mod.rs @@ -1,3 +1,4 @@ +pub mod capture; pub mod clear; pub mod compact; pub mod context; @@ -27,6 +28,7 @@ use profile::AgentSubcommand; use prompts::PromptsArgs; use tools::ToolsArgs; +use crate::cli::chat::cli::capture::CaptureSubcommand; use crate::cli::chat::cli::subscribe::SubscribeArgs; use crate::cli::chat::cli::usage::UsageArgs; use crate::cli::chat::consts::AGENT_MIGRATION_DOC_URL; @@ -85,6 +87,8 @@ pub enum SlashCommand { Persist(PersistSubcommand), // #[command(flatten)] // Root(RootSubcommand), + #[command(subcommand)] + Capture(CaptureSubcommand), } impl SlashCommand { @@ -146,6 +150,7 @@ impl SlashCommand { // skip_printing_tools: true, // }) // }, + Self::Capture(subcommand) => subcommand.execute(os, session).await, } } @@ -171,6 +176,7 @@ impl SlashCommand { PersistSubcommand::Save { .. } => "save", PersistSubcommand::Load { .. } => "load", }, + Self::Capture(_) => "capture", } } diff --git a/crates/chat-cli/src/cli/chat/consts.rs b/crates/chat-cli/src/cli/chat/consts.rs index 21f6b1b8ea..c47398d453 100644 --- a/crates/chat-cli/src/cli/chat/consts.rs +++ b/crates/chat-cli/src/cli/chat/consts.rs @@ -32,3 +32,6 @@ pub const USER_AGENT_ENV_VAR: &str = "AWS_EXECUTION_ENV"; pub const USER_AGENT_APP_NAME: &str = "AmazonQ-For-CLI"; pub const USER_AGENT_VERSION_KEY: &str = "Version"; pub const USER_AGENT_VERSION_VALUE: &str = env!("CARGO_PKG_VERSION"); + +// The shadow repo path that MUST be appended with a session-specific directory +pub const SHADOW_REPO_DIR: &str = "./.amazonq/captures"; diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index ca7b87d2c4..1a081f1616 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -66,6 +66,7 @@ use crate::cli::agent::hook::{ HookTrigger, }; use crate::cli::chat::ChatError; +use crate::cli::chat::capture::CaptureManager; use crate::cli::chat::cli::model::{ ModelInfo, get_model_info, @@ -124,6 +125,8 @@ pub struct ConversationState { /// Maps from a file path to [FileLineTracker] #[serde(default)] pub file_line_tracker: HashMap, + + pub capture_manager: Option, } impl ConversationState { @@ -180,6 +183,7 @@ impl ConversationState { model: None, model_info: model, file_line_tracker: HashMap::new(), + capture_manager: None, } } @@ -699,6 +703,11 @@ impl ConversationState { } self.transcript.push_back(message); } + + pub fn pop_from_history(&mut self) -> Option<()> { + self.history.pop_back()?; + Some(()) + } } /// Represents a conversation state that can be converted into a [FigConversationState] (the type diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 0941df73a3..067d03fdd7 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -6,7 +6,11 @@ mod error_formatter; mod input_source; mod message; mod parse; -use std::path::MAIN_SEPARATOR; +use std::path::{ + MAIN_SEPARATOR, + PathBuf, +}; +pub mod capture; mod line_tracker; mod parser; mod prompt; @@ -18,6 +22,7 @@ mod token_counter; pub mod tool_manager; pub mod tools; pub mod util; + use std::borrow::Cow; use std::collections::{ HashMap, @@ -132,6 +137,11 @@ use crate::api_client::{ use crate::auth::AuthError; use crate::auth::builder_id::is_idc_user; use crate::cli::agent::Agents; +use crate::cli::chat::capture::{ + CAPTURE_MESSAGE_MAX_LENGTH, + CaptureManager, + truncate_message, +}; use crate::cli::chat::cli::SlashCommand; use crate::cli::chat::cli::prompts::{ GetPromptError, @@ -1198,6 +1208,29 @@ impl ChatSession { } } + // Initialize checkpointing if possible + let capture_manager = if CaptureManager::is_git_installed() { + let path = + PathBuf::from(crate::cli::chat::consts::SHADOW_REPO_DIR).join(self.conversation.conversation_id()); + match CaptureManager::init(path) { + Ok(manager) => { + execute!(self.stderr, style::Print("Captures are enabled!\n\n".blue().bold()))?; + Some(manager) + }, + Err(e) => { + execute!( + self.stderr, + style::Print(format!("Captures could not be enabled: {e}\n").blue()) + )?; + None + } + } + } else { + execute!(self.stderr, style::Print("Captures could not be enabled because git is not installed. Please install git to enable checkpointing features.\n".blue()))?; + None + }; + self.conversation.capture_manager = capture_manager; + if let Some(user_input) = self.initial_input.take() { self.inner = Some(ChatState::HandleInput { input: user_input }); } @@ -1920,6 +1953,33 @@ impl ChatSession { } execute!(self.stdout, style::Print("\n"))?; + let tag = if invoke_result.is_ok() { + if let Some(mut manager) = self.conversation.capture_manager.take() { + let res = if manager.has_uncommitted_changes() { + manager.num_tools_this_turn += 1; + let tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn); + let commit_message = match tool.tool.get_summary() { + Some(summary) => summary, + None => tool.tool.display_name(), + }; + if let Err(e) = + manager.create_capture(&tag, &commit_message, self.conversation.history().len() + 1) + { + debug!("{e}"); + } + tag + } else { + "".to_string() + }; + self.conversation.capture_manager = Some(manager); + res + } else { + "".to_string() + } + } else { + "".to_string() + }; + let tool_end_time = Instant::now(); let tool_time = tool_end_time.duration_since(tool_start); tool_telemetry = tool_telemetry.and_modify(|ev| { @@ -1962,8 +2022,11 @@ impl ChatSession { style::SetAttribute(Attribute::Bold), style::Print(format!(" ● Completed in {}s", tool_time)), style::SetForegroundColor(Color::Reset), - style::Print("\n\n"), )?; + if !tag.is_empty() { + execute!(self.stdout, style::Print(format!(" [{tag}]").blue().bold()))?; + } + execute!(self.stdout, style::Print("\n\n"))?; tool_telemetry = tool_telemetry.and_modify(|ev| ev.is_success = Some(true)); if let Tool::Custom(_) = &tool.tool { @@ -2084,6 +2147,12 @@ impl ChatSession { state: crate::api_client::model::ConversationState, request_metadata_lock: Arc>>, ) -> Result { + let user_message = match state.user_input_message.content.len() { + 0 => "No description provided".to_string(), + _ => state.user_input_message.content.clone(), + }; + + let mut rx = self.send_message(os, state, request_metadata_lock, None).await?; let request_id = rx.request_id().map(String::from); @@ -2356,6 +2425,33 @@ impl ChatSession { self.pending_tool_index = None; self.tool_turn_start_time = None; + if let Some(mut manager) = self.conversation.capture_manager.take() { + if manager.num_tools_this_turn > 0 { + manager.num_turns += 1; + match manager.create_capture( + &manager.num_turns.to_string(), + &truncate_message(&user_message, CAPTURE_MESSAGE_MAX_LENGTH), + self.conversation.history().len(), + ) { + Ok(_) => execute!( + self.stderr, + style::Print(style::Print( + format!("Created snapshot: {}\n\n", manager.num_turns).blue().bold() + )) + )?, + Err(e) => { + execute!( + self.stderr, + style::Print(style::Print( + format!("Could not create automatic snapshot: {}\n\n", e).blue() + )) + )?; + }, + } + } + self.conversation.capture_manager = Some(manager) + } + self.send_chat_telemetry(os, TelemetryResult::Succeeded, None, None, None, true) .await; diff --git a/crates/chat-cli/src/cli/chat/tools/fs_write.rs b/crates/chat-cli/src/cli/chat/tools/fs_write.rs index e305d9304c..edc3eae18f 100644 --- a/crates/chat-cli/src/cli/chat/tools/fs_write.rs +++ b/crates/chat-cli/src/cli/chat/tools/fs_write.rs @@ -406,7 +406,7 @@ impl FsWrite { } /// Returns the summary from any variant of the FsWrite enum - fn get_summary(&self) -> Option<&String> { + pub fn get_summary(&self) -> Option<&String> { match self { FsWrite::Create { summary, .. } => summary.as_ref(), FsWrite::StrReplace { summary, .. } => summary.as_ref(), diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index ea2aef2529..c82d970a7b 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -169,6 +169,15 @@ impl Tool { _ => None, } } + + /// Returns the tool's summary if available + pub fn get_summary(&self) -> Option { + match self { + Tool::FsWrite(fs_write) => fs_write.get_summary().cloned(), + Tool::ExecuteCommand(execute_cmd) => execute_cmd.summary.clone(), + _ => None, + } + } } /// A tool specification to be sent to the model as part of a conversation. Maps to From c5c033f8ca1622d3c1a2fa7f21c437368a7c64b7 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Mon, 18 Aug 2025 15:03:47 -0700 Subject: [PATCH 02/31] feat: Add new checkpointing functionality using git CLI Updates: - Only works if the user has git installed - Supports auto initialization if the user is in a git repo, manual if not - UI ported over from dedicated file tools implementation --- crates/chat-cli/src/cli/chat/capture.rs | 376 ++++++++++++++++++++ crates/chat-cli/src/cli/chat/cli/capture.rs | 262 ++++++++++++++ crates/chat-cli/src/cli/chat/consts.rs | 3 - crates/chat-cli/src/cli/chat/mod.rs | 75 ++-- 4 files changed, 676 insertions(+), 40 deletions(-) create mode 100644 crates/chat-cli/src/cli/chat/capture.rs create mode 100644 crates/chat-cli/src/cli/chat/cli/capture.rs diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs new file mode 100644 index 0000000000..3164ea62a3 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -0,0 +1,376 @@ +use std::collections::{ + HashMap, + HashSet, +}; +use std::path::{ + Path, + PathBuf, +}; +use std::process::Command; + +use amzn_codewhisperer_client::meta; +use bstr::ByteSlice; +use chrono::{ + DateTime, + Local, +}; +use eyre::{ + Result, + bail, + eyre, +}; +use serde::{ + Deserialize, + Serialize, +}; +use walkdir::WalkDir; + +use crate::cli::ConversationState; +use crate::os::Os; + +// The shadow repo path that MUST be appended with a session-specific directory +pub const SHADOW_REPO_DIR: &str = "./.amazonq/captures"; + +pub const CAPTURE_TEST_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; + +// The maximum size in bytes of the cwd for automatically enabling captures +// Currently set to 4GB +pub const AUTOMATIC_INIT_THRESHOLD: u64 = 4_294_967_296; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CaptureManager { + shadow_repo_path: PathBuf, + pub captures: Vec, + pub tag_to_index: HashMap, + pub num_turns: usize, + pub num_tools_this_turn: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Capture { + pub tag: String, + pub timestamp: DateTime, + pub message: String, + pub history_index: usize, + + pub is_turn: bool, + pub tool_name: Option, +} + +impl CaptureManager { + pub fn auto_init(shadow_path: impl AsRef) -> Result { + if !is_git_installed() { + bail!( + "Captures could not be enabled because git is not installed. Please install git to enable checkpointing features." + ); + } + if !is_in_git_repo() { + bail!("Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures."); + } + + let path = shadow_path.as_ref(); + + let repo_root = get_git_repo_root()?; + let output = Command::new("git") + .args(&[ + "clone", + "--depth=1", + &repo_root.to_string_lossy(), + &path.to_string_lossy(), + ]) + .output()?; + + if !output.status.success() { + bail!("git clone failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + let cloned_git_dir = path.join(".git"); + + // Remove remote origin to sever connection + let output = Command::new("git") + .args(&[ + &format!("--git-dir={}", cloned_git_dir.display()), + "remote", + "remove", + "origin", + ]) + .output()?; + + if !output.status.success() { + bail!("git remote remove failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + + config(&cloned_git_dir.to_string_lossy())?; + stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Inital capture", "0")?; + + let mut captures = Vec::new(); + captures.push(Capture { + tag: "0".to_string(), + timestamp: Local::now(), + message: "Initial capture".to_string(), + history_index: 0, + is_turn: true, + tool_name: None, + }); + + let mut tag_to_index = HashMap::new(); + tag_to_index.insert("0".to_string(), 0); + + Ok(Self { + shadow_repo_path: cloned_git_dir, + captures, + tag_to_index, + num_turns: 0, + num_tools_this_turn: 0, + }) + } + + + pub fn manual_init(path: impl AsRef) -> Result { + let path = path.as_ref(); + + let output = Command::new("git") + .args(&["init", "--bare", &path.to_string_lossy()]) + .output()?; + + if !output.status.success() { + bail!("git init failed: {}", String::from_utf8_lossy(&output.stderr)); + } + + config(&path.to_string_lossy())?; + stage_commit_tag(&path.to_string_lossy(), "Initial capture", "0")?; + + let mut captures = Vec::new(); + captures.push(Capture { + tag: "0".to_string(), + timestamp: Local::now(), + message: "Initial capture".to_string(), + history_index: 0, + is_turn: true, + tool_name: None, + }); + + let mut tag_to_index = HashMap::new(); + tag_to_index.insert("0".to_string(), 0); + + Ok(Self { + shadow_repo_path: path.to_path_buf(), + captures, + tag_to_index, + num_turns: 0, + num_tools_this_turn: 0, + }) + } + + pub fn create_capture(&mut self, tag: &str, commit_message: &str, history_index: usize, is_turn: bool, tool_name: Option) -> Result<()> { + stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), commit_message, tag)?; + + self.captures.push(Capture { + tag: tag.to_string(), + timestamp: Local::now(), + message: commit_message.to_string(), + history_index, + is_turn, + tool_name + }); + self.tag_to_index.insert(tag.to_string(), self.captures.len() - 1); + + Ok(()) + } + + pub fn restore_capture(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { + let Some(index) = self.tag_to_index.get(tag) else { + bail!("No capture with tag {tag}"); + }; + let capture = &self.captures[*index]; + let output = if !hard { + Command::new("git") + .args([ + &format!("--git-dir={}", self.shadow_repo_path.display()), + "--work-tree=.", + "checkout", + tag, + "--", + ".", + ]) + .output()? + } else { + Command::new("git") + .args([ + &format!("--git-dir={}", self.shadow_repo_path.display()), + "--work-tree=.", + "reset", + "--hard", + tag, + ]) + .output()? + }; + + if !output.status.success() { + bail!("git reset failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + for _ in capture.history_index..conversation.history().len() { + conversation + .pop_from_history() + .ok_or(eyre!("Tried to pop from empty history"))?; + } + + Ok(()) + } + + pub fn has_uncommitted_changes(&self) -> bool { + Command::new("git") + .args([ + &format!("--git-dir={}", self.shadow_repo_path.display()), + "--work-tree=.", + "status", + "--porcelain", + ]) + .output() + .map(|output| !output.stdout.is_empty()) + .unwrap_or(false) + } +} + +pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 20; + +pub fn truncate_message(s: &str, max_chars: usize) -> String { + if s.len() <= max_chars { + return s.to_string(); + } + + let truncated = &s[..max_chars]; + match truncated.rfind(' ') { + Some(pos) => format!("{}...", &truncated[..pos]), + None => format!("{}...", truncated), + } +} + +pub fn is_git_installed() -> bool { + Command::new("git") + .arg("--version") + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub fn is_in_git_repo() -> bool { + Command::new("git") + .args(&["rev-parse", "--is-inside-work-tree"]) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +pub fn get_git_repo_root() -> Result { + let output = Command::new("git").args(&["rev-parse", "--show-toplevel"]).output()?; + + if !output.status.success() { + bail!( + "Failed to get git repo root: {}", + String::from_utf8_lossy(&output.stdout) + ); + } + + let root = String::from_utf8(output.stdout)?.trim().to_string(); + Ok(PathBuf::from(root)) +} + +pub fn within_git_threshold(os: &Os) -> Result { + let ignored_paths = get_ignored_paths()?; + let mut total = 0; + for entry in WalkDir::new(os.env.current_dir()?) + .into_iter() + .filter_entry(|e| !ignored_paths.contains(e.path())) + { + let entry = entry?; + if entry.file_type().is_file() { + total += entry.metadata()?.len(); + if total > AUTOMATIC_INIT_THRESHOLD { + return Ok(false); + } + } + } + + Ok(true) +} + +fn get_ignored_paths() -> Result> { + let rev_parse_output = Command::new("git").args(&["rev-parse", "--show-toplevel"]).output()?; + let repo_root = PathBuf::from(rev_parse_output.stdout.to_str()?); + + let output = Command::new("git") + .args(&["ls-files", "--ignored", "--exclude-standard", "-o"]) + .output()?; + + let files = String::from_utf8(output.stdout)? + .lines() + .map(|s| repo_root.join(s)) + .collect(); + + Ok(files) +} + +pub fn stage_commit_tag(shadow_path: &str, commit_message: &str, tag: &str) -> Result<()> { + let git_dir_arg = format!("--git-dir={}", shadow_path); + let output = Command::new("git") + .args([&git_dir_arg, "--work-tree=.", "add", "-A"]) + .output()?; + + if !output.status.success() { + bail!("git add failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + let output = Command::new("git") + .args([ + &git_dir_arg, + "--work-tree=.", + "commit", + "--allow-empty", + "--no-verify", + "-m", + commit_message, + ]) + .output()?; + + if !output.status.success() { + bail!("git commit failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + let output = Command::new("git").args([&git_dir_arg, "tag", tag]).output()?; + + if !output.status.success() { + bail!("git tag failed: {}", String::from_utf8_lossy(&output.stdout)); + } + Ok(()) +} + +pub fn config(shadow_path: &str) -> Result<()> { + let git_dir_arg = format!("--git-dir={}", shadow_path); + let output = Command::new("git") + .args([&git_dir_arg, "config", "user.name", "Q"]) + .output()?; + + if !output.status.success() { + bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + let output = Command::new("git") + .args([&git_dir_arg, "config", "user.email", "qcli@local"]) + .output()?; + + if !output.status.success() { + bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); + } + + let output = Command::new("git") + .args([&git_dir_arg, "config", "core.preloadindex", "true"]) + .output()?; + + if !output.status.success() { + bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); + } + Ok(()) +} \ No newline at end of file diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs new file mode 100644 index 0000000000..3d57f88660 --- /dev/null +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -0,0 +1,262 @@ +use std::io::Write; +use std::path::PathBuf; + +use clap::Subcommand; +use crossterm::style::{StyledContent, Stylize}; +use crossterm::{ + execute, + style, +}; +use dialoguer::FuzzySelect; +use eyre::{ + Result, + bail, +}; +use rustls::crypto::tls13::expand; + +use crate::cli::chat::capture::{self, Capture, CaptureManager, CAPTURE_TEST_DIR}; +use crate::cli::chat::{ + ChatError, + ChatSession, + ChatState, +}; +use crate::os::Os; + +#[derive(Debug, PartialEq, Subcommand)] +pub enum CaptureSubcommand { + + /// Manually initialize captures + Init, + + /// Revert to a specified checkpoint or the most recent if none specified + // Hard will reset all files and delete files that were created since the + // checkpoint + // Not specifying hard only restores modifications/deletions of tracked files + Restore { + tag: Option, + #[arg(long)] + hard: bool, + }, + + /// View all checkpoints + List { + #[arg(short, long)] + limit: Option, + }, + + /// Delete shadow repository + Clean, + + /// Display more information about a turn-level snapshot + Expand { tag: String }, +} + +impl CaptureSubcommand { + pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { + if let CaptureSubcommand::Init = self { + if session.conversation.capture_manager.is_some() { + execute!( + session.stderr, + style::Print("Captures are already enabled for this session! Use /capture list to see current captures.\n".blue()) + )?; + } else { + let start = std::time::Instant::now(); + session.conversation.capture_manager = Some(CaptureManager::manual_init(PathBuf::from(CAPTURE_TEST_DIR).join(session.conversation.conversation_id())) + .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?); + execute!( + session.stderr, + style::Print( + format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) + .blue() + .bold() + ) + )?; + } + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + let Some(manager) = session.conversation.capture_manager.take() else { + execute!( + session.stderr, + style::Print("Captures are not enabled for this session\n".blue()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + match self { + Self::Init => (), + Self::Restore { tag, hard } => { + let tag = if let Some(tag) = tag { + tag + } else { + // If the user doesn't provide a tag, allow them to fuzzy select a capture + let display_entries = match gather_all_turn_captures(&manager){ + Ok(entries) => entries, + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom(format!("Error getting captures: {e}\n").into())); + }, + }; + if let Some(index) = + fuzzy_select_captures(&display_entries, "Select a capture to restore:") + { + if index < display_entries.len() { + display_entries[index].tag.to_string() + } else { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom( + format!("Selecting capture with index {index} failed\n").into(), + )); + } + } else { + session.conversation.capture_manager = Some(manager); + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + }; + let result = manager + .restore_capture(&mut session.conversation, &tag, hard); + match result { + Ok(_) => { + execute!( + session.stderr, + style::Print(format!("Restored capture: {tag}\n").blue().bold()) + )?; + }, + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom(format!("Could not restore capture: {}", e).into())); + } + } + } + Self::List { limit } => match print_turn_captures(&manager, &mut session.stderr, limit) { + Ok(_) => (), + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom( + format!("Could not display all captures: {e}").into(), + )); + }, + }, + Self::Clean => {}, + Self::Expand { tag } => match expand_capture(&manager, &mut session.stderr, tag.clone()) { + Ok(_) => (), + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom( + format!("Could not expand checkpoint with tag {}: {e}", tag).into(), + )); + }, + }, + } + + session.conversation.capture_manager = Some(manager); + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } +} + +pub struct CaptureDisplayEntry { + pub tag: String, + pub display_parts: Vec>, +} + +impl TryFrom<&Capture> for CaptureDisplayEntry { + type Error = eyre::Report; + + fn try_from(value: &Capture) -> std::result::Result { + let tag = value.tag.clone(); + let mut parts = Vec::new(); + if value.is_turn { + parts.push(format!("[{tag}] ",).blue()); + parts.push(format!("{} - {}", value.timestamp.format("%Y-%m-%d %H:%M:%S"), value.message).reset()); + } else { + parts.push(format!("[{tag}] ",).blue()); + parts.push(format!("{}: ", value.tool_name.clone().unwrap_or("No tool provided".to_string())).magenta()); + parts.push(format!("{}", value.message).reset()); + } + + Ok(Self { tag, display_parts: parts }) + } +} + +impl std::fmt::Display for CaptureDisplayEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for part in self.display_parts.iter() { + write!(f, "{}", part)?; + } + Ok(()) + } +} + +fn print_turn_captures(manager: &CaptureManager, output: &mut impl Write, limit: Option) -> Result<()> { + let display_entries = gather_all_turn_captures(manager)?; + for entry in display_entries.iter().take(limit.unwrap_or(display_entries.len())) { + execute!(output, style::Print(entry), style::Print("\n"))?; + } + Ok(()) +} + +fn gather_all_turn_captures(manager: &CaptureManager) -> Result> { + let mut displays = Vec::new(); + for capture in manager.captures.iter() { + if !capture.is_turn { + continue; + } + displays.push(CaptureDisplayEntry::try_from(capture).unwrap()); + } + Ok(displays) +} + +fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: String) -> Result<()> { + let capture_index = match manager.tag_to_index.get(&tag) { + Some(i) => i, + None => { + execute!(output, style::Print(format!("Checkpoint with tag '{tag}' does not exist! Use /checkpoint list to see available checkpoints\n").blue()))?; + return Ok(()); + }, + }; + let capture = &manager.captures[*capture_index]; + let display_entry = CaptureDisplayEntry::try_from(capture)?; + execute!(output, style::Print(display_entry), style::Print("\n"))?; + + // If the user tries to expand a tool-level checkpoint, return early + if !capture.is_turn { + return Ok(()); + } else { + let mut display_vec = Vec::new(); + for i in (0..*capture_index).rev() { + let capture = &manager.captures[i]; + if capture.is_turn { + break; + } + display_vec.push(CaptureDisplayEntry::try_from(&manager.captures[i])?); + } + + for entry in display_vec.iter().rev() { + execute!( + output, + style::Print(" └─ ".blue()), + style::Print(entry), + style::Print("\n") + )?; + } + } + + Ok(()) +} + +fn fuzzy_select_captures(entries: &Vec, prompt_str: &str) -> Option { + FuzzySelect::new() + .with_prompt(prompt_str) + .items(&entries) + .report(false) + .interact_opt() + .unwrap_or(None) +} diff --git a/crates/chat-cli/src/cli/chat/consts.rs b/crates/chat-cli/src/cli/chat/consts.rs index c47398d453..21f6b1b8ea 100644 --- a/crates/chat-cli/src/cli/chat/consts.rs +++ b/crates/chat-cli/src/cli/chat/consts.rs @@ -32,6 +32,3 @@ pub const USER_AGENT_ENV_VAR: &str = "AWS_EXECUTION_ENV"; pub const USER_AGENT_APP_NAME: &str = "AmazonQ-For-CLI"; pub const USER_AGENT_VERSION_KEY: &str = "Version"; pub const USER_AGENT_VERSION_VALUE: &str = env!("CARGO_PKG_VERSION"); - -// The shadow repo path that MUST be appended with a session-specific directory -pub const SHADOW_REPO_DIR: &str = "./.amazonq/captures"; diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 067d03fdd7..0602a214d7 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -139,6 +139,7 @@ use crate::auth::builder_id::is_idc_user; use crate::cli::agent::Agents; use crate::cli::chat::capture::{ CAPTURE_MESSAGE_MAX_LENGTH, + CAPTURE_TEST_DIR, CaptureManager, truncate_message, }; @@ -1209,25 +1210,26 @@ impl ChatSession { } // Initialize checkpointing if possible - let capture_manager = if CaptureManager::is_git_installed() { - let path = - PathBuf::from(crate::cli::chat::consts::SHADOW_REPO_DIR).join(self.conversation.conversation_id()); - match CaptureManager::init(path) { - Ok(manager) => { - execute!(self.stderr, style::Print("Captures are enabled!\n\n".blue().bold()))?; - Some(manager) - }, - Err(e) => { - execute!( - self.stderr, - style::Print(format!("Captures could not be enabled: {e}\n").blue()) - )?; - None - } - } - } else { - execute!(self.stderr, style::Print("Captures could not be enabled because git is not installed. Please install git to enable checkpointing features.\n".blue()))?; - None + let path = + // os.env.home().unwrap_or(os.env.current_dir()?).join(crate::cli::chat::consts::SHADOW_REPO_DIR).join(self.conversation.conversation_id()); + PathBuf::from(CAPTURE_TEST_DIR).join(self.conversation.conversation_id()); + let start = std::time::Instant::now(); + let capture_manager = match CaptureManager::auto_init(path) { + Ok(manager) => { + execute!( + self.stderr, + style::Print( + format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) + .blue() + .bold() + ) + )?; + Some(manager) + }, + Err(e) => { + execute!(self.stderr, style::Print(format!("{e}\n").blue()))?; + None + }, }; self.conversation.capture_manager = capture_manager; @@ -1955,24 +1957,21 @@ impl ChatSession { let tag = if invoke_result.is_ok() { if let Some(mut manager) = self.conversation.capture_manager.take() { - let res = if manager.has_uncommitted_changes() { - manager.num_tools_this_turn += 1; - let tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn); - let commit_message = match tool.tool.get_summary() { - Some(summary) => summary, - None => tool.tool.display_name(), - }; - if let Err(e) = - manager.create_capture(&tag, &commit_message, self.conversation.history().len() + 1) - { - debug!("{e}"); - } - tag - } else { - "".to_string() + let mut tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn + 1); + let commit_message = match tool.tool.get_summary() { + Some(summary) => summary, + None => tool.tool.display_name(), }; + + match manager.create_capture(&tag, &commit_message, self.conversation.history().len() + 1, false, Some(tool.name.clone())) { + Ok(_) => manager.num_tools_this_turn += 1, + Err(e) => { + debug!("{e}"); + tag = "".to_string(); + }, + } self.conversation.capture_manager = Some(manager); - res + tag } else { "".to_string() } @@ -2152,7 +2151,6 @@ impl ChatSession { _ => state.user_input_message.content.clone(), }; - let mut rx = self.send_message(os, state, request_metadata_lock, None).await?; let request_id = rx.request_id().map(String::from); @@ -2428,10 +2426,13 @@ impl ChatSession { if let Some(mut manager) = self.conversation.capture_manager.take() { if manager.num_tools_this_turn > 0 { manager.num_turns += 1; + manager.num_tools_this_turn = 0; match manager.create_capture( &manager.num_turns.to_string(), &truncate_message(&user_message, CAPTURE_MESSAGE_MAX_LENGTH), self.conversation.history().len(), + true, + None, ) { Ok(_) => execute!( self.stderr, @@ -2449,7 +2450,7 @@ impl ChatSession { }, } } - self.conversation.capture_manager = Some(manager) + self.conversation.capture_manager = Some(manager); } self.send_chat_telemetry(os, TelemetryResult::Succeeded, None, None, None, true) From 17608393a1efa7b7bc56d5c0f197cbe8b170f6ce Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Tue, 19 Aug 2025 12:54:00 -0700 Subject: [PATCH 03/31] feat: Add user message for turn-level checkpoints, clean command Updates: - The clean subcommand will delete the shadow repo - The description for turn-level checkpoints is a truncated version of the user's last message --- crates/chat-cli/src/cli/chat/capture.rs | 107 ++++++-------------- crates/chat-cli/src/cli/chat/cli/capture.rs | 72 ++++++++----- crates/chat-cli/src/cli/chat/mod.rs | 33 +++--- 3 files changed, 99 insertions(+), 113 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 3164ea62a3..acf9516352 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -1,15 +1,10 @@ -use std::collections::{ - HashMap, - HashSet, -}; +use std::collections::HashMap; use std::path::{ Path, PathBuf, }; use std::process::Command; -use amzn_codewhisperer_client::meta; -use bstr::ByteSlice; use chrono::{ DateTime, Local, @@ -23,27 +18,25 @@ use serde::{ Deserialize, Serialize, }; -use walkdir::WalkDir; use crate::cli::ConversationState; use crate::os::Os; // The shadow repo path that MUST be appended with a session-specific directory -pub const SHADOW_REPO_DIR: &str = "./.amazonq/captures"; - -pub const CAPTURE_TEST_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; +pub const SHADOW_REPO_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; // The maximum size in bytes of the cwd for automatically enabling captures // Currently set to 4GB -pub const AUTOMATIC_INIT_THRESHOLD: u64 = 4_294_967_296; +// pub const AUTOMATIC_INIT_THRESHOLD: u64 = 4_294_967_296; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureManager { - shadow_repo_path: PathBuf, + pub shadow_repo_path: PathBuf, pub captures: Vec, pub tag_to_index: HashMap, pub num_turns: usize, pub num_tools_this_turn: usize, + pub last_user_message: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -58,18 +51,19 @@ pub struct Capture { } impl CaptureManager { - pub fn auto_init(shadow_path: impl AsRef) -> Result { + pub async fn auto_init(os: &Os, shadow_path: impl AsRef) -> Result { if !is_git_installed() { - bail!( - "Captures could not be enabled because git is not installed. Please install git to enable checkpointing features." - ); + bail!("Captures could not be enabled because git is not installed."); } if !is_in_git_repo() { - bail!("Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures."); + bail!( + "Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures." + ); } let path = shadow_path.as_ref(); - + os.fs.create_dir_all(path).await?; + let repo_root = get_git_repo_root()?; let output = Command::new("git") .args(&[ @@ -100,10 +94,9 @@ impl CaptureManager { bail!("git remote remove failed: {}", String::from_utf8_lossy(&output.stdout)); } - config(&cloned_git_dir.to_string_lossy())?; stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Inital capture", "0")?; - + let mut captures = Vec::new(); captures.push(Capture { tag: "0".to_string(), @@ -123,13 +116,14 @@ impl CaptureManager { tag_to_index, num_turns: 0, num_tools_this_turn: 0, + last_user_message: None, }) } - - pub fn manual_init(path: impl AsRef) -> Result { + pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { let path = path.as_ref(); - + os.fs.create_dir_all(path).await?; + let output = Command::new("git") .args(&["init", "--bare", &path.to_string_lossy()]) .output()?; @@ -140,7 +134,7 @@ impl CaptureManager { config(&path.to_string_lossy())?; stage_commit_tag(&path.to_string_lossy(), "Initial capture", "0")?; - + let mut captures = Vec::new(); captures.push(Capture { tag: "0".to_string(), @@ -160,10 +154,18 @@ impl CaptureManager { tag_to_index, num_turns: 0, num_tools_this_turn: 0, + last_user_message: None }) } - pub fn create_capture(&mut self, tag: &str, commit_message: &str, history_index: usize, is_turn: bool, tool_name: Option) -> Result<()> { + pub fn create_capture( + &mut self, + tag: &str, + commit_message: &str, + history_index: usize, + is_turn: bool, + tool_name: Option, + ) -> Result<()> { stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), commit_message, tag)?; self.captures.push(Capture { @@ -172,7 +174,7 @@ impl CaptureManager { message: commit_message.to_string(), history_index, is_turn, - tool_name + tool_name, }); self.tag_to_index.insert(tag.to_string(), self.captures.len() - 1); @@ -220,21 +222,13 @@ impl CaptureManager { Ok(()) } - pub fn has_uncommitted_changes(&self) -> bool { - Command::new("git") - .args([ - &format!("--git-dir={}", self.shadow_repo_path.display()), - "--work-tree=.", - "status", - "--porcelain", - ]) - .output() - .map(|output| !output.stdout.is_empty()) - .unwrap_or(false) + pub async fn clean(&self, os: &Os) -> Result<()> { + os.fs.remove_dir_all(&self.shadow_repo_path).await?; + Ok(()) } } -pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 20; +pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; pub fn truncate_message(s: &str, max_chars: usize) -> String { if s.len() <= max_chars { @@ -278,41 +272,6 @@ pub fn get_git_repo_root() -> Result { Ok(PathBuf::from(root)) } -pub fn within_git_threshold(os: &Os) -> Result { - let ignored_paths = get_ignored_paths()?; - let mut total = 0; - for entry in WalkDir::new(os.env.current_dir()?) - .into_iter() - .filter_entry(|e| !ignored_paths.contains(e.path())) - { - let entry = entry?; - if entry.file_type().is_file() { - total += entry.metadata()?.len(); - if total > AUTOMATIC_INIT_THRESHOLD { - return Ok(false); - } - } - } - - Ok(true) -} - -fn get_ignored_paths() -> Result> { - let rev_parse_output = Command::new("git").args(&["rev-parse", "--show-toplevel"]).output()?; - let repo_root = PathBuf::from(rev_parse_output.stdout.to_str()?); - - let output = Command::new("git") - .args(&["ls-files", "--ignored", "--exclude-standard", "-o"]) - .output()?; - - let files = String::from_utf8(output.stdout)? - .lines() - .map(|s| repo_root.join(s)) - .collect(); - - Ok(files) -} - pub fn stage_commit_tag(shadow_path: &str, commit_message: &str, tag: &str) -> Result<()> { let git_dir_arg = format!("--git-dir={}", shadow_path); let output = Command::new("git") @@ -373,4 +332,4 @@ pub fn config(shadow_path: &str) -> Result<()> { bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); } Ok(()) -} \ No newline at end of file +} diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 3d57f88660..458e2449d4 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -2,19 +2,20 @@ use std::io::Write; use std::path::PathBuf; use clap::Subcommand; -use crossterm::style::{StyledContent, Stylize}; +use crossterm::style::{ + StyledContent, + Stylize, +}; use crossterm::{ execute, style, }; use dialoguer::FuzzySelect; -use eyre::{ - Result, - bail, -}; -use rustls::crypto::tls13::expand; +use eyre::Result; -use crate::cli::chat::capture::{self, Capture, CaptureManager, CAPTURE_TEST_DIR}; +use crate::cli::chat::capture::{ + Capture, CaptureManager, SHADOW_REPO_DIR +}; use crate::cli::chat::{ ChatError, ChatSession, @@ -24,7 +25,6 @@ use crate::os::Os; #[derive(Debug, PartialEq, Subcommand)] pub enum CaptureSubcommand { - /// Manually initialize captures Init, @@ -57,16 +57,23 @@ impl CaptureSubcommand { if session.conversation.capture_manager.is_some() { execute!( session.stderr, - style::Print("Captures are already enabled for this session! Use /capture list to see current captures.\n".blue()) + style::Print( + "Captures are already enabled for this session! Use /capture list to see current captures.\n" + .blue() + ) )?; } else { + let path = PathBuf::from(SHADOW_REPO_DIR).join(session.conversation.conversation_id()); let start = std::time::Instant::now(); - session.conversation.capture_manager = Some(CaptureManager::manual_init(PathBuf::from(CAPTURE_TEST_DIR).join(session.conversation.conversation_id())) - .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?); + session.conversation.capture_manager = Some( + CaptureManager::manual_init(os, path) + .await + .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?, + ); execute!( session.stderr, style::Print( - format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) + format!("Captures are enabled! (took {:.2}s)\n", start.elapsed().as_secs_f32()) .blue() .bold() ) @@ -94,16 +101,14 @@ impl CaptureSubcommand { tag } else { // If the user doesn't provide a tag, allow them to fuzzy select a capture - let display_entries = match gather_all_turn_captures(&manager){ + let display_entries = match gather_all_turn_captures(&manager) { Ok(entries) => entries, Err(e) => { session.conversation.capture_manager = Some(manager); return Err(ChatError::Custom(format!("Error getting captures: {e}\n").into())); }, }; - if let Some(index) = - fuzzy_select_captures(&display_entries, "Select a capture to restore:") - { + if let Some(index) = fuzzy_select_captures(&display_entries, "Select a capture to restore:") { if index < display_entries.len() { display_entries[index].tag.to_string() } else { @@ -119,8 +124,7 @@ impl CaptureSubcommand { }); } }; - let result = manager - .restore_capture(&mut session.conversation, &tag, hard); + let result = manager.restore_capture(&mut session.conversation, &tag, hard); match result { Ok(_) => { execute!( @@ -131,19 +135,26 @@ impl CaptureSubcommand { Err(e) => { session.conversation.capture_manager = Some(manager); return Err(ChatError::Custom(format!("Could not restore capture: {}", e).into())); - } + }, } - } + }, Self::List { limit } => match print_turn_captures(&manager, &mut session.stderr, limit) { Ok(_) => (), Err(e) => { session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom( - format!("Could not display all captures: {e}").into(), - )); + return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); }, }, - Self::Clean => {}, + Self::Clean => { + match manager.clean(os).await { + Ok(()) => execute!(session.stderr, style::Print(format!("Delete shadow repository.\n").blue().bold()))?, + Err(e) => { + session.conversation.capture_manager = None; + return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); + } + } + session.conversation.capture_manager = None; + }, Self::Expand { tag } => match expand_capture(&manager, &mut session.stderr, tag.clone()) { Ok(_) => (), Err(e) => { @@ -178,11 +189,20 @@ impl TryFrom<&Capture> for CaptureDisplayEntry { parts.push(format!("{} - {}", value.timestamp.format("%Y-%m-%d %H:%M:%S"), value.message).reset()); } else { parts.push(format!("[{tag}] ",).blue()); - parts.push(format!("{}: ", value.tool_name.clone().unwrap_or("No tool provided".to_string())).magenta()); + parts.push( + format!( + "{}: ", + value.tool_name.clone().unwrap_or("No tool provided".to_string()) + ) + .magenta(), + ); parts.push(format!("{}", value.message).reset()); } - Ok(Self { tag, display_parts: parts }) + Ok(Self { + tag, + display_parts: parts, + }) } } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 0602a214d7..8c465f09c0 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -138,10 +138,7 @@ use crate::auth::AuthError; use crate::auth::builder_id::is_idc_user; use crate::cli::agent::Agents; use crate::cli::chat::capture::{ - CAPTURE_MESSAGE_MAX_LENGTH, - CAPTURE_TEST_DIR, - CaptureManager, - truncate_message, + truncate_message, CaptureManager, CAPTURE_MESSAGE_MAX_LENGTH, SHADOW_REPO_DIR }; use crate::cli::chat::cli::SlashCommand; use crate::cli::chat::cli::prompts::{ @@ -1210,11 +1207,9 @@ impl ChatSession { } // Initialize checkpointing if possible - let path = - // os.env.home().unwrap_or(os.env.current_dir()?).join(crate::cli::chat::consts::SHADOW_REPO_DIR).join(self.conversation.conversation_id()); - PathBuf::from(CAPTURE_TEST_DIR).join(self.conversation.conversation_id()); + let path = PathBuf::from(SHADOW_REPO_DIR).join(self.conversation.conversation_id()); let start = std::time::Instant::now(); - let capture_manager = match CaptureManager::auto_init(path) { + let capture_manager = match CaptureManager::auto_init(os, path).await { Ok(manager) => { execute!( self.stderr, @@ -1227,7 +1222,7 @@ impl ChatSession { Some(manager) }, Err(e) => { - execute!(self.stderr, style::Print(format!("{e}\n").blue()))?; + execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; None }, }; @@ -1608,6 +1603,13 @@ impl ChatSession { None => return Ok(ChatState::Exit), }; + if let Some(mut manager) = self.conversation.capture_manager.take() { + if manager.last_user_message.is_none() && !user_input.is_empty() { + manager.last_user_message = Some(user_input.clone()); + } + self.conversation.capture_manager = Some(manager); + } + self.conversation.append_user_transcript(&user_input); Ok(ChatState::HandleInput { input: user_input }) } @@ -2146,10 +2148,6 @@ impl ChatSession { state: crate::api_client::model::ConversationState, request_metadata_lock: Arc>>, ) -> Result { - let user_message = match state.user_input_message.content.len() { - 0 => "No description provided".to_string(), - _ => state.user_input_message.content.clone(), - }; let mut rx = self.send_message(os, state, request_metadata_lock, None).await?; @@ -2424,9 +2422,18 @@ impl ChatSession { self.tool_turn_start_time = None; if let Some(mut manager) = self.conversation.capture_manager.take() { + let user_message = match manager.last_user_message { + Some(message) => { + let message = message.clone(); + manager.last_user_message = None; + message + }, + None => "No description provided".to_string() + }; if manager.num_tools_this_turn > 0 { manager.num_turns += 1; manager.num_tools_this_turn = 0; + match manager.create_capture( &manager.num_turns.to_string(), &truncate_message(&user_message, CAPTURE_MESSAGE_MAX_LENGTH), From 5a27533a535ce5ca865182ad02d519113e513b55 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Tue, 19 Aug 2025 14:10:50 -0700 Subject: [PATCH 04/31] fix: Fix shadow repo deletion logic Updates: - Running the clean subcommand now properly deletes the entire shadow repo for both automatic and manual modes --- crates/chat-cli/src/cli/chat/capture.rs | 8 +++++++- crates/chat-cli/src/cli/chat/cli/capture.rs | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index acf9516352..ef1b370555 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -223,7 +223,13 @@ impl CaptureManager { } pub async fn clean(&self, os: &Os) -> Result<()> { - os.fs.remove_dir_all(&self.shadow_repo_path).await?; + let path = if self.shadow_repo_path.file_name().unwrap() == ".git" { + self.shadow_repo_path.parent().unwrap() + } else { + self.shadow_repo_path.as_path() + }; + println!("Deleting path: {}", path.display()); + os.fs.remove_dir_all(path).await?; Ok(()) } } diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 458e2449d4..c49aa940ae 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -147,10 +147,10 @@ impl CaptureSubcommand { }, Self::Clean => { match manager.clean(os).await { - Ok(()) => execute!(session.stderr, style::Print(format!("Delete shadow repository.\n").blue().bold()))?, + Ok(()) => execute!(session.stderr, style::Print(format!("Deleted shadow repository.\n").blue().bold()))?, Err(e) => { session.conversation.capture_manager = None; - return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); + return Err(ChatError::Custom(format!("Could not delete shadow repo: {e}").into())); } } session.conversation.capture_manager = None; From 643912d7757ef2c54bf0b6706ac77afc9ad3ace8 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Tue, 19 Aug 2025 14:38:00 -0700 Subject: [PATCH 05/31] chore: Run formatter and fix clippy warnings --- crates/chat-cli/src/cli/chat/capture.rs | 24 ++++++++++----------- crates/chat-cli/src/cli/chat/cli/capture.rs | 19 ++++++++++------ crates/chat-cli/src/cli/chat/mod.rs | 16 ++++++++++---- 3 files changed, 35 insertions(+), 24 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index ef1b370555..00c0c23b45 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -63,10 +63,10 @@ impl CaptureManager { let path = shadow_path.as_ref(); os.fs.create_dir_all(path).await?; - + let repo_root = get_git_repo_root()?; let output = Command::new("git") - .args(&[ + .args([ "clone", "--depth=1", &repo_root.to_string_lossy(), @@ -82,7 +82,7 @@ impl CaptureManager { // Remove remote origin to sever connection let output = Command::new("git") - .args(&[ + .args([ &format!("--git-dir={}", cloned_git_dir.display()), "remote", "remove", @@ -97,15 +97,14 @@ impl CaptureManager { config(&cloned_git_dir.to_string_lossy())?; stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Inital capture", "0")?; - let mut captures = Vec::new(); - captures.push(Capture { + let captures = vec![Capture { tag: "0".to_string(), timestamp: Local::now(), message: "Initial capture".to_string(), history_index: 0, is_turn: true, tool_name: None, - }); + }]; let mut tag_to_index = HashMap::new(); tag_to_index.insert("0".to_string(), 0); @@ -125,7 +124,7 @@ impl CaptureManager { os.fs.create_dir_all(path).await?; let output = Command::new("git") - .args(&["init", "--bare", &path.to_string_lossy()]) + .args(["init", "--bare", &path.to_string_lossy()]) .output()?; if !output.status.success() { @@ -135,15 +134,14 @@ impl CaptureManager { config(&path.to_string_lossy())?; stage_commit_tag(&path.to_string_lossy(), "Initial capture", "0")?; - let mut captures = Vec::new(); - captures.push(Capture { + let captures = vec![Capture { tag: "0".to_string(), timestamp: Local::now(), message: "Initial capture".to_string(), history_index: 0, is_turn: true, tool_name: None, - }); + }]; let mut tag_to_index = HashMap::new(); tag_to_index.insert("0".to_string(), 0); @@ -154,7 +152,7 @@ impl CaptureManager { tag_to_index, num_turns: 0, num_tools_this_turn: 0, - last_user_message: None + last_user_message: None, }) } @@ -258,14 +256,14 @@ pub fn is_git_installed() -> bool { pub fn is_in_git_repo() -> bool { Command::new("git") - .args(&["rev-parse", "--is-inside-work-tree"]) + .args(["rev-parse", "--is-inside-work-tree"]) .output() .map(|output| output.status.success()) .unwrap_or(false) } pub fn get_git_repo_root() -> Result { - let output = Command::new("git").args(&["rev-parse", "--show-toplevel"]).output()?; + let output = Command::new("git").args(["rev-parse", "--show-toplevel"]).output()?; if !output.status.success() { bail!( diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index c49aa940ae..494860038d 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -14,7 +14,9 @@ use dialoguer::FuzzySelect; use eyre::Result; use crate::cli::chat::capture::{ - Capture, CaptureManager, SHADOW_REPO_DIR + Capture, + CaptureManager, + SHADOW_REPO_DIR, }; use crate::cli::chat::{ ChatError, @@ -110,7 +112,7 @@ impl CaptureSubcommand { }; if let Some(index) = fuzzy_select_captures(&display_entries, "Select a capture to restore:") { if index < display_entries.len() { - display_entries[index].tag.to_string() + display_entries[index].tag.clone() } else { session.conversation.capture_manager = Some(manager); return Err(ChatError::Custom( @@ -147,11 +149,14 @@ impl CaptureSubcommand { }, Self::Clean => { match manager.clean(os).await { - Ok(()) => execute!(session.stderr, style::Print(format!("Deleted shadow repository.\n").blue().bold()))?, + Ok(()) => execute!( + session.stderr, + style::Print("Deleted shadow repository.\n".to_string().blue().bold()) + )?, Err(e) => { session.conversation.capture_manager = None; return Err(ChatError::Custom(format!("Could not delete shadow repo: {e}").into())); - } + }, } session.conversation.capture_manager = None; }, @@ -196,7 +201,7 @@ impl TryFrom<&Capture> for CaptureDisplayEntry { ) .magenta(), ); - parts.push(format!("{}", value.message).reset()); + parts.push(value.message.clone().reset()); } Ok(Self { @@ -272,10 +277,10 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: String Ok(()) } -fn fuzzy_select_captures(entries: &Vec, prompt_str: &str) -> Option { +fn fuzzy_select_captures(entries: &[CaptureDisplayEntry], prompt_str: &str) -> Option { FuzzySelect::new() .with_prompt(prompt_str) - .items(&entries) + .items(entries) .report(false) .interact_opt() .unwrap_or(None) diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 8c465f09c0..deeb7a754b 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -138,7 +138,10 @@ use crate::auth::AuthError; use crate::auth::builder_id::is_idc_user; use crate::cli::agent::Agents; use crate::cli::chat::capture::{ - truncate_message, CaptureManager, CAPTURE_MESSAGE_MAX_LENGTH, SHADOW_REPO_DIR + CAPTURE_MESSAGE_MAX_LENGTH, + CaptureManager, + SHADOW_REPO_DIR, + truncate_message, }; use crate::cli::chat::cli::SlashCommand; use crate::cli::chat::cli::prompts::{ @@ -1965,7 +1968,13 @@ impl ChatSession { None => tool.tool.display_name(), }; - match manager.create_capture(&tag, &commit_message, self.conversation.history().len() + 1, false, Some(tool.name.clone())) { + match manager.create_capture( + &tag, + &commit_message, + self.conversation.history().len() + 1, + false, + Some(tool.name.clone()), + ) { Ok(_) => manager.num_tools_this_turn += 1, Err(e) => { debug!("{e}"); @@ -2148,7 +2157,6 @@ impl ChatSession { state: crate::api_client::model::ConversationState, request_metadata_lock: Arc>>, ) -> Result { - let mut rx = self.send_message(os, state, request_metadata_lock, None).await?; let request_id = rx.request_id().map(String::from); @@ -2428,7 +2436,7 @@ impl ChatSession { manager.last_user_message = None; message }, - None => "No description provided".to_string() + None => "No description provided".to_string(), }; if manager.num_tools_this_turn > 0 { manager.num_turns += 1; From 7f0cba9a72b0b4384cbe9842583e0b00e5a60587 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Tue, 19 Aug 2025 16:07:49 -0700 Subject: [PATCH 06/31] feat: Add checkpoint diff Updates: - Users can now view diffs between checkpoints - Fixed tool-level checkpoint display handling --- crates/chat-cli/src/cli/chat/capture.rs | 60 ++++++++++++++------- crates/chat-cli/src/cli/chat/cli/capture.rs | 9 +++- crates/chat-cli/src/cli/chat/mod.rs | 54 ++++++++++++------- 3 files changed, 83 insertions(+), 40 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 00c0c23b45..8530191ae9 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -95,7 +95,7 @@ impl CaptureManager { } config(&cloned_git_dir.to_string_lossy())?; - stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Inital capture", "0")?; + stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Initial capture", "0")?; let captures = vec![Capture { tag: "0".to_string(), @@ -180,30 +180,15 @@ impl CaptureManager { } pub fn restore_capture(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { - let Some(index) = self.tag_to_index.get(tag) else { - bail!("No capture with tag {tag}"); - }; - let capture = &self.captures[*index]; + let capture = self.get_capture(tag)?; + let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); let output = if !hard { Command::new("git") - .args([ - &format!("--git-dir={}", self.shadow_repo_path.display()), - "--work-tree=.", - "checkout", - tag, - "--", - ".", - ]) + .args([&git_dir_arg, "--work-tree=.", "checkout", tag, "--", "."]) .output()? } else { Command::new("git") - .args([ - &format!("--git-dir={}", self.shadow_repo_path.display()), - "--work-tree=.", - "reset", - "--hard", - tag, - ]) + .args([&git_dir_arg, "--work-tree=.", "reset", "--hard", tag]) .output()? }; @@ -230,6 +215,41 @@ impl CaptureManager { os.fs.remove_dir_all(path).await?; Ok(()) } + + pub fn diff(&self, tag1: &str, tag2: &str) -> Result { + let _ = self.get_capture(tag1)?; + let _ = self.get_capture(tag2)?; + let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); + + let output = Command::new("git") + .args([&git_dir_arg, "diff", tag1, tag2, "--stat", "--color=always"]) + .output()?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + bail!("Failed to get diff: {}", String::from_utf8_lossy(&output.stderr)); + } + } + + fn get_capture(&self, tag: &str) -> Result<&Capture> { + let Some(index) = self.tag_to_index.get(tag) else { + bail!("No capture with tag {tag}"); + }; + Ok(&self.captures[*index]) + } + + pub fn has_uncommitted_changes(&self) -> Result { + let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); + let output = Command::new("git") + .args([&git_dir_arg, "--work-tree=.", "status", "--porcelain"]) + .output()?; + + if !output.status.success() { + bail!("git status failed: {}", String::from_utf8_lossy(&output.stderr)); + } + Ok(!output.stdout.is_empty()) + } } pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 494860038d..dcffa7bd3b 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -51,6 +51,9 @@ pub enum CaptureSubcommand { /// Display more information about a turn-level snapshot Expand { tag: String }, + + /// Display a diff between two captures + Diff { tag1: String, tag2: String }, } impl CaptureSubcommand { @@ -151,7 +154,7 @@ impl CaptureSubcommand { match manager.clean(os).await { Ok(()) => execute!( session.stderr, - style::Print("Deleted shadow repository.\n".to_string().blue().bold()) + style::Print("Deleted shadow repository.\n".blue().bold()) )?, Err(e) => { session.conversation.capture_manager = None; @@ -169,6 +172,10 @@ impl CaptureSubcommand { )); }, }, + Self::Diff { tag1, tag2 } => match manager.diff(&tag1, &tag2) { + Ok(diff) => execute!(session.stderr, style::Print(diff))?, + Err(e) => return Err(ChatError::Custom(format!("Could not display diff: {e}").into())), + }, } session.conversation.capture_manager = Some(manager); diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index deeb7a754b..0db3d4b5ef 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1962,32 +1962,48 @@ impl ChatSession { let tag = if invoke_result.is_ok() { if let Some(mut manager) = self.conversation.capture_manager.take() { - let mut tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn + 1); - let commit_message = match tool.tool.get_summary() { - Some(summary) => summary, - None => tool.tool.display_name(), - }; - - match manager.create_capture( - &tag, - &commit_message, - self.conversation.history().len() + 1, - false, - Some(tool.name.clone()), - ) { - Ok(_) => manager.num_tools_this_turn += 1, + let has_uncommitted = match manager.has_uncommitted_changes() { + Ok(b) => b, Err(e) => { - debug!("{e}"); - tag = "".to_string(); + execute!( + self.stderr, + style::Print(format!("Could not check if uncommitted changes exist: {e}\n").blue()) + )?; + execute!(self.stderr, style::Print("Saving anyways...\n".blue()))?; + true }, - } + }; + let tag = if has_uncommitted { + let mut tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn + 1); + let commit_message = match tool.tool.get_summary() { + Some(summary) => summary, + None => tool.tool.display_name(), + }; + + match manager.create_capture( + &tag, + &commit_message, + self.conversation.history().len() + 1, + false, + Some(tool.name.clone()), + ) { + Ok(_) => manager.num_tools_this_turn += 1, + Err(e) => { + debug!("{e}"); + tag = String::new(); + }, + } + tag + } else { + String::new() + }; self.conversation.capture_manager = Some(manager); tag } else { - "".to_string() + String::new() } } else { - "".to_string() + String::new() }; let tool_end_time = Instant::now(); From 30b5a950f69e32e310c86a8c4f975bbb7d76449e Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Thu, 21 Aug 2025 16:22:53 -0700 Subject: [PATCH 07/31] fix: Fix last messsage handling for checkpoints Updates: - Checkpoints now (hopefully) correctly display the correct turn-specific user message - Added slash command auto completion --- crates/chat-cli/src/cli/chat/capture.rs | 4 ++++ crates/chat-cli/src/cli/chat/mod.rs | 5 ++++- crates/chat-cli/src/cli/chat/prompt.rs | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 8530191ae9..a46348ec0b 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -36,7 +36,9 @@ pub struct CaptureManager { pub tag_to_index: HashMap, pub num_turns: usize, pub num_tools_this_turn: usize, + pub last_user_message: Option, + pub user_message_lock: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -116,6 +118,7 @@ impl CaptureManager { num_turns: 0, num_tools_this_turn: 0, last_user_message: None, + user_message_lock: false, }) } @@ -153,6 +156,7 @@ impl CaptureManager { num_turns: 0, num_tools_this_turn: 0, last_user_message: None, + user_message_lock: false, }) } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 0db3d4b5ef..0b0fceb641 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -6,6 +6,7 @@ mod error_formatter; mod input_source; mod message; mod parse; +use std::mem::ManuallyDrop; use std::path::{ MAIN_SEPARATOR, PathBuf, @@ -1607,8 +1608,9 @@ impl ChatSession { }; if let Some(mut manager) = self.conversation.capture_manager.take() { - if manager.last_user_message.is_none() && !user_input.is_empty() { + if !manager.user_message_lock { manager.last_user_message = Some(user_input.clone()); + manager.user_message_lock = true; } self.conversation.capture_manager = Some(manager); } @@ -2446,6 +2448,7 @@ impl ChatSession { self.tool_turn_start_time = None; if let Some(mut manager) = self.conversation.capture_manager.take() { + manager.user_message_lock = false; let user_message = match manager.last_user_message { Some(message) => { let message = message.clone(); diff --git a/crates/chat-cli/src/cli/chat/prompt.rs b/crates/chat-cli/src/cli/chat/prompt.rs index 291fe35ba3..fdb1a18025 100644 --- a/crates/chat-cli/src/cli/chat/prompt.rs +++ b/crates/chat-cli/src/cli/chat/prompt.rs @@ -83,6 +83,12 @@ pub const COMMANDS: &[&str] = &[ "/save", "/load", "/subscribe", + "/capture init", + "/capture restore", + "/capture list", + "/capture clean", + "/capture expand", + "/capture diff", ]; /// Complete commands that start with a slash From 8eb341a13525a2269cff5cd06bf33eb3f3d7febf Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Fri, 22 Aug 2025 14:13:42 -0700 Subject: [PATCH 08/31] fix: Fix commit message handling again --- crates/chat-cli/src/cli/chat/capture.rs | 21 +-------------------- crates/chat-cli/src/cli/chat/mod.rs | 17 ++++++++--------- 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index a46348ec0b..8b4cb9dc24 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -68,12 +68,7 @@ impl CaptureManager { let repo_root = get_git_repo_root()?; let output = Command::new("git") - .args([ - "clone", - "--depth=1", - &repo_root.to_string_lossy(), - &path.to_string_lossy(), - ]) + .args(["clone", &repo_root.to_string_lossy(), &path.to_string_lossy()]) .output()?; if !output.status.success() { @@ -82,20 +77,6 @@ impl CaptureManager { let cloned_git_dir = path.join(".git"); - // Remove remote origin to sever connection - let output = Command::new("git") - .args([ - &format!("--git-dir={}", cloned_git_dir.display()), - "remote", - "remove", - "origin", - ]) - .output()?; - - if !output.status.success() { - bail!("git remote remove failed: {}", String::from_utf8_lossy(&output.stdout)); - } - config(&cloned_git_dir.to_string_lossy())?; stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Initial capture", "0")?; diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 0b0fceb641..53e43517cf 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -6,7 +6,6 @@ mod error_formatter; mod input_source; mod message; mod parse; -use std::mem::ManuallyDrop; use std::path::{ MAIN_SEPARATOR, PathBuf, @@ -1607,14 +1606,6 @@ impl ChatSession { None => return Ok(ChatState::Exit), }; - if let Some(mut manager) = self.conversation.capture_manager.take() { - if !manager.user_message_lock { - manager.last_user_message = Some(user_input.clone()); - manager.user_message_lock = true; - } - self.conversation.capture_manager = Some(manager); - } - self.conversation.append_user_transcript(&user_input); Ok(ChatState::HandleInput { input: user_input }) } @@ -1771,6 +1762,14 @@ impl ChatSession { skip_printing_tools: false, }) } else { + // Track this as the last user message for checkpointing + if let Some(mut manager) = self.conversation.capture_manager.take() { + if !manager.user_message_lock { + manager.last_user_message = Some(user_input.clone()); + manager.user_message_lock = true; + } + self.conversation.capture_manager = Some(manager); + } // Check for a pending tool approval if let Some(index) = self.pending_tool_index { let is_trust = ["t", "T"].contains(&input); From eae59d58dc44ab67675b646e71a878afe2b46fd1 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Thu, 11 Sep 2025 11:24:26 -0700 Subject: [PATCH 09/31] chore: Run formatter --- crates/chat-cli/src/cli/chat/cli/capture.rs | 4 ++-- crates/chat-cli/src/cli/chat/conversation.rs | 2 +- crates/chat-cli/src/cli/chat/tools/use_aws.rs | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 18f5f314ce..23abc4901d 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -10,7 +10,7 @@ use crossterm::{ execute, style, }; -use dialoguer::FuzzySelect; +use dialoguer::Select; use eyre::Result; use crate::cli::chat::capture::{ @@ -285,7 +285,7 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: String } fn fuzzy_select_captures(entries: &[CaptureDisplayEntry], prompt_str: &str) -> Option { - FuzzySelect::new() + Select::with_theme(&crate::util::dialoguer_theme()) .with_prompt(prompt_str) .items(entries) .report(false) diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index 16006db51b..e62f54d7a5 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -863,7 +863,7 @@ Return only the JSON configuration, no additional text.", self.history.pop_back()?; Some(()) } - + /// Swapping agent involves the following: /// - Reinstantiate the context manager /// - Swap agent on tool manager diff --git a/crates/chat-cli/src/cli/chat/tools/use_aws.rs b/crates/chat-cli/src/cli/chat/tools/use_aws.rs index 4ea40c80c4..3bb9611ea4 100644 --- a/crates/chat-cli/src/cli/chat/tools/use_aws.rs +++ b/crates/chat-cli/src/cli/chat/tools/use_aws.rs @@ -397,7 +397,7 @@ mod tests { #[tokio::test] async fn test_eval_perm_auto_allow_readonly_default() { let os = Os::new().await.unwrap(); - + // Test read-only operation with default settings (auto_allow_readonly = false) let readonly_cmd = use_aws! {{ "service_name": "s3", @@ -429,7 +429,7 @@ mod tests { #[tokio::test] async fn test_eval_perm_auto_allow_readonly_enabled() { let os = Os::new().await.unwrap(); - + let agent = Agent { name: "test_agent".to_string(), tools_settings: { @@ -475,7 +475,7 @@ mod tests { #[tokio::test] async fn test_eval_perm_auto_allow_readonly_with_denied_services() { let os = Os::new().await.unwrap(); - + let agent = Agent { name: "test_agent".to_string(), tools_settings: { From bc239937ea484e4bbd74062934b86871a4afb030 Mon Sep 17 00:00:00 2001 From: kiran-garre Date: Thu, 11 Sep 2025 11:25:05 -0700 Subject: [PATCH 10/31] Removed old comment --- crates/chat-cli/src/cli/chat/capture.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 896e5701ef..d5a080481e 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -25,10 +25,6 @@ use crate::os::Os; // The shadow repo path that MUST be appended with a session-specific directory pub const SHADOW_REPO_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; -// The maximum size in bytes of the cwd for automatically enabling captures -// Currently set to 4GB -// pub const AUTOMATIC_INIT_THRESHOLD: u64 = 4_294_967_296; - // CURRENT APPROACH: // We only enable automatically enable checkpoints when the user is already in a git repo. // Otherwise, the user must manually enable checkpoints using `/checkpoint init`. From a97f3f834ef45a33f92fe3ca5c80554143b498cb Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Fri, 12 Sep 2025 14:15:39 -0700 Subject: [PATCH 11/31] define a global capture dirctory --- crates/chat-cli/src/cli/chat/capture.rs | 2 +- crates/chat-cli/src/cli/chat/cli/capture.rs | 6 +++--- crates/chat-cli/src/util/directories.rs | 5 +++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index d5a080481e..10cf8a93bb 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -23,7 +23,7 @@ use crate::cli::ConversationState; use crate::os::Os; // The shadow repo path that MUST be appended with a session-specific directory -pub const SHADOW_REPO_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; +// pub const SHADOW_REPO_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; // CURRENT APPROACH: // We only enable automatically enable checkpoints when the user is already in a git repo. diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 23abc4901d..e62a998d6d 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -1,5 +1,4 @@ use std::io::Write; -use std::path::PathBuf; use clap::Subcommand; use crossterm::style::{ @@ -16,7 +15,6 @@ use eyre::Result; use crate::cli::chat::capture::{ Capture, CaptureManager, - SHADOW_REPO_DIR, }; use crate::cli::chat::{ ChatError, @@ -24,6 +22,7 @@ use crate::cli::chat::{ ChatState, }; use crate::os::Os; +use crate::util::directories::get_shadow_repo_dir; #[derive(Debug, PartialEq, Subcommand)] pub enum CaptureSubcommand { @@ -68,7 +67,8 @@ impl CaptureSubcommand { ) )?; } else { - let path = PathBuf::from(SHADOW_REPO_DIR).join(session.conversation.conversation_id()); + let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) + .map_err(|e| ChatError::Custom(e.to_string().into()))?; let start = std::time::Instant::now(); session.conversation.capture_manager = Some( CaptureManager::manual_init(os, path) diff --git a/crates/chat-cli/src/util/directories.rs b/crates/chat-cli/src/util/directories.rs index 89c6f3bc4e..a5db72006b 100644 --- a/crates/chat-cli/src/util/directories.rs +++ b/crates/chat-cli/src/util/directories.rs @@ -43,6 +43,7 @@ pub enum DirectoryError { type Result = std::result::Result; const WORKSPACE_AGENT_DIR_RELATIVE: &str = ".amazonq/cli-agents"; +const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/.amazonq/cli-captures"; const GLOBAL_AGENT_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/cli-agents"; const CLI_BASH_HISTORY_PATH: &str = ".aws/amazonq/.cli_bash_history"; @@ -249,6 +250,10 @@ pub fn get_mcp_auth_dir(os: &Os) -> Result { Ok(home_dir(os)?.join(".aws").join("sso").join("cache")) } +pub fn get_shadow_repo_dir(os: &Os, conversation_id: String) -> Result { + Ok(home_dir(os)?.join(GLOBAL_SHADOW_REPO_DIR).join(conversation_id)) +} + /// Generate a unique identifier for an agent based on its path and name fn generate_agent_unique_id(agent: &crate::cli::Agent) -> String { use std::collections::hash_map::DefaultHasher; From 25645b186132b5d3f2f4dd24179e9b711c36ab6d Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 15 Sep 2025 16:24:24 -0700 Subject: [PATCH 12/31] revise the capture path --- crates/chat-cli/src/cli/chat/capture.rs | 2 +- crates/chat-cli/src/cli/chat/mod.rs | 11 ++++------- crates/chat-cli/src/util/directories.rs | 2 +- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 10cf8a93bb..c547ea5ec2 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -38,7 +38,7 @@ pub struct CaptureManager { pub tag_to_index: HashMap, pub num_turns: usize, pub num_tools_this_turn: usize, - + // test pub last_user_message: Option, pub user_message_lock: bool, } diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 3269514717..6389db049c 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -5,10 +5,7 @@ mod conversation; mod input_source; mod message; mod parse; -use std::path::{ - MAIN_SEPARATOR, - PathBuf, -}; +use std::path::MAIN_SEPARATOR; pub mod capture; mod line_tracker; mod parser; @@ -149,7 +146,6 @@ use crate::cli::agent::Agents; use crate::cli::chat::capture::{ CAPTURE_MESSAGE_MAX_LENGTH, CaptureManager, - SHADOW_REPO_DIR, truncate_message, }; use crate::cli::chat::cli::SlashCommand; @@ -175,6 +171,7 @@ use crate::telemetry::{ TelemetryResult, get_error_reason, }; +use crate::util::directories::get_shadow_repo_dir; use crate::util::{ MCP_SERVER_TOOL_DELIMITER, directories, @@ -1333,9 +1330,9 @@ impl ChatSession { } // Initialize checkpointing if possible - let path = PathBuf::from(SHADOW_REPO_DIR).join(self.conversation.conversation_id()); + let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; let start = std::time::Instant::now(); - let capture_manager = match CaptureManager::auto_init(os, path).await { + let capture_manager = match CaptureManager::auto_init(os, &path).await { Ok(manager) => { execute!( self.stderr, diff --git a/crates/chat-cli/src/util/directories.rs b/crates/chat-cli/src/util/directories.rs index a5db72006b..0429a0698f 100644 --- a/crates/chat-cli/src/util/directories.rs +++ b/crates/chat-cli/src/util/directories.rs @@ -43,7 +43,7 @@ pub enum DirectoryError { type Result = std::result::Result; const WORKSPACE_AGENT_DIR_RELATIVE: &str = ".amazonq/cli-agents"; -const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/.amazonq/cli-captures"; +const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/amazonq/cli-captures"; const GLOBAL_AGENT_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/cli-agents"; const CLI_BASH_HISTORY_PATH: &str = ".aws/amazonq/.cli_bash_history"; From 0f740513da927404e4d2c11408e27f1e40761b2f Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 16 Sep 2025 11:59:49 -0700 Subject: [PATCH 13/31] fix cpature clean bug --- crates/chat-cli/src/cli/chat/capture.rs | 72 ++++----------------- crates/chat-cli/src/cli/chat/cli/capture.rs | 3 + 2 files changed, 15 insertions(+), 60 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index c547ea5ec2..faae5c08f5 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -23,7 +23,7 @@ use crate::cli::ConversationState; use crate::os::Os; // The shadow repo path that MUST be appended with a session-specific directory -// pub const SHADOW_REPO_DIR: &str = "/Users/kiranbug/.amazonq/captures/"; +// pub const SHADOW_REPO_DIR: &str = "/Users/aws/.amazonq/cli-captures/"; // CURRENT APPROACH: // We only enable automatically enable checkpoints when the user is already in a git repo. @@ -64,45 +64,8 @@ impl CaptureManager { "Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures." ); } - - let path = shadow_path.as_ref(); - os.fs.create_dir_all(path).await?; - - let repo_root = get_git_repo_root()?; - let output = Command::new("git") - .args(["clone", &repo_root.to_string_lossy(), &path.to_string_lossy()]) - .output()?; - - if !output.status.success() { - bail!("git clone failed: {}", String::from_utf8_lossy(&output.stdout)); - } - - let cloned_git_dir = path.join(".git"); - - config(&cloned_git_dir.to_string_lossy())?; - stage_commit_tag(&cloned_git_dir.to_string_lossy(), "Initial capture", "0")?; - - let captures = vec![Capture { - tag: "0".to_string(), - timestamp: Local::now(), - message: "Initial capture".to_string(), - history_index: 0, - is_turn: true, - tool_name: None, - }]; - - let mut tag_to_index = HashMap::new(); - tag_to_index.insert("0".to_string(), 0); - - Ok(Self { - shadow_repo_path: cloned_git_dir, - captures, - tag_to_index, - num_turns: 0, - num_tools_this_turn: 0, - last_user_message: None, - user_message_lock: false, - }) + // Reuse bare repo init to keep storage model consistent. + Self::manual_init(os, shadow_path).await } pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { @@ -192,13 +155,16 @@ impl CaptureManager { Ok(()) } - pub async fn clean(&self, os: &Os) -> Result<()> { - let path = if self.shadow_repo_path.file_name().unwrap() == ".git" { - self.shadow_repo_path.parent().unwrap() - } else { - self.shadow_repo_path.as_path() - }; + pub async fn clean(&self, os: &Os) -> eyre::Result<()> { + // In bare mode, shadow_repo_path is the session directory to delete. + let path = &self.shadow_repo_path; + println!("Deleting path: {}", path.display()); + + if !path.exists() { + return Ok(()); + } + os.fs.remove_dir_all(path).await?; Ok(()) } @@ -269,20 +235,6 @@ pub fn is_in_git_repo() -> bool { .unwrap_or(false) } -pub fn get_git_repo_root() -> Result { - let output = Command::new("git").args(["rev-parse", "--show-toplevel"]).output()?; - - if !output.status.success() { - bail!( - "Failed to get git repo root: {}", - String::from_utf8_lossy(&output.stdout) - ); - } - - let root = String::from_utf8(output.stdout)?.trim().to_string(); - Ok(PathBuf::from(root)) -} - pub fn stage_commit_tag(shadow_path: &str, commit_message: &str, tag: &str) -> Result<()> { let git_dir_arg = format!("--git-dir={}", shadow_path); let output = Command::new("git") diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index e62a998d6d..5b3c774192 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -162,6 +162,9 @@ impl CaptureSubcommand { }, } session.conversation.capture_manager = None; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); }, Self::Expand { tag } => match expand_capture(&manager, &mut session.stderr, tag.clone()) { Ok(_) => (), From a9801c0b913902a5bb1a5501d44318c3a2dbbf46 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 16 Sep 2025 15:35:52 -0700 Subject: [PATCH 14/31] add a clean all flag --- crates/chat-cli/src/cli/chat/capture.rs | 24 ++++++++++++++++ crates/chat-cli/src/cli/chat/cli/capture.rs | 31 +++++++++++++++++---- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index faae5c08f5..c28cc8b7b1 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -169,6 +169,30 @@ impl CaptureManager { Ok(()) } + /// Delete the entire captures root (i.e., remove all session captures). + /// This re-creates the empty root directory after deletion. + pub async fn clean_all_sessions(&self, os: &Os) -> Result<()> { + let root = self + .shadow_repo_path + .parent() + .ok_or_else(|| eyre!("Could not determine captures root"))?; + + // Safety guard: ensure last component looks like "cli-captures" + if root + .file_name() + .and_then(|s| s.to_str()) + .map(|s| s.contains("captures")) + != Some(true) + { + bail!("Refusing to delete unexpected parent directory: {}", root.display()); + } + + println!("Deleting captures root: {}", root.display()); + os.fs.remove_dir_all(root).await?; + os.fs.create_dir_all(root).await?; // recreate empty root + Ok(()) + } + pub fn diff(&self, tag1: &str, tag2: &str) -> Result { let _ = self.get_capture(tag1)?; let _ = self.get_capture(tag2)?; diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 5b3c774192..823ebca89b 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -46,7 +46,11 @@ pub enum CaptureSubcommand { }, /// Delete shadow repository - Clean, + Clean { + /// Delete the entire captures root (all sessions) + #[arg(long)] + all: bool, + }, /// Display more information about a turn-level checkpoint Expand { tag: String }, @@ -150,15 +154,32 @@ impl CaptureSubcommand { return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); }, }, - Self::Clean => { - match manager.clean(os).await { + Self::Clean { all } => { + let res = if all { + manager.clean_all_sessions(os).await + } else { + manager.clean(os).await + }; + match res { Ok(()) => execute!( session.stderr, - style::Print("Deleted shadow repository.\n".blue().bold()) + style::Print( + if all { + "Deleted all session captures under the captures root.\n" + } else { + "Deleted shadow repository for this session.\n" + } + .blue() + .bold() + ) )?, Err(e) => { session.conversation.capture_manager = None; - return Err(ChatError::Custom(format!("Could not delete shadow repo: {e}").into())); + return Err(ChatError::Custom(if all { + format!("Could not delete captures root: {e}").into() + } else { + format!("Could not delete shadow repo: {e}").into() + })); }, } session.conversation.capture_manager = None; From e1a3cfeeb66ac2f53e396b50156e122979c68d45 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 16 Sep 2025 16:16:47 -0700 Subject: [PATCH 15/31] add auto drop method for capture feature --- crates/chat-cli/src/cli/chat/capture.rs | 35 +++++++++++++++++++++++-- crates/chat-cli/src/cli/chat/mod.rs | 2 +- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index c28cc8b7b1..64d99ef508 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -38,9 +38,11 @@ pub struct CaptureManager { pub tag_to_index: HashMap, pub num_turns: usize, pub num_tools_this_turn: usize, - // test pub last_user_message: Option, pub user_message_lock: bool, + /// If true, delete the current session's shadow repo directory when dropped. + #[serde(default)] + pub clean_on_drop: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -65,7 +67,10 @@ impl CaptureManager { ); } // Reuse bare repo init to keep storage model consistent. - Self::manual_init(os, shadow_path).await + let mut s = Self::manual_init(os, shadow_path).await?; + // Auto-initialized captures are considered ephemeral: clean when session ends. + s.clean_on_drop = true; + Ok(s) } pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { @@ -103,6 +108,7 @@ impl CaptureManager { num_tools_this_turn: 0, last_user_message: None, user_message_lock: false, + clean_on_drop: false, }) } @@ -229,6 +235,31 @@ impl CaptureManager { } } +impl Drop for CaptureManager { + fn drop(&mut self) { + // Only clean if this session was auto-initialized (ephemeral). + if !self.clean_on_drop { + return; + } + let path = self.shadow_repo_path.clone(); + // Prefer spawning on an active Tokio runtime if available. + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { + // Best-effort: swallow errors; we don't want to block Drop or panic here. + let _ = tokio::fs::remove_dir_all(path).await; + }); + return; + } + + // Fallback: spawn a detached background thread. Still non-blocking. + let _ = std::thread::Builder::new() + .name("q-capture-cleaner".into()) + .spawn(move || { + let _ = std::fs::remove_dir_all(&path); + }); + } +} + pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; pub fn truncate_message(s: &str, max_chars: usize) -> String { diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 6389db049c..83071befa4 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -2841,7 +2841,7 @@ impl ChatSession { if let Some(mut manager) = self.conversation.capture_manager.take() { manager.user_message_lock = false; - let user_message = match manager.last_user_message { + let user_message = match &manager.last_user_message { Some(message) => { let message = message.clone(); manager.last_user_message = None; From a44f8142ec6826f4914b1325f8df7a82d49f667b Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Tue, 16 Sep 2025 19:46:34 -0700 Subject: [PATCH 16/31] support file details when expand --- crates/chat-cli/src/cli/chat/capture.rs | 117 ++++++++++++++++++-- crates/chat-cli/src/cli/chat/cli/capture.rs | 71 ++++++++++-- crates/chat-cli/src/cli/chat/mod.rs | 4 +- 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 64d99ef508..3af753b4f9 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -9,6 +9,7 @@ use chrono::{ DateTime, Local, }; +use crossterm::style::Stylize; use eyre::{ Result, bail, @@ -21,7 +22,6 @@ use serde::{ use crate::cli::ConversationState; use crate::os::Os; - // The shadow repo path that MUST be appended with a session-specific directory // pub const SHADOW_REPO_DIR: &str = "/Users/aws/.amazonq/cli-captures/"; @@ -43,6 +43,16 @@ pub struct CaptureManager { /// If true, delete the current session's shadow repo directory when dropped. #[serde(default)] pub clean_on_drop: bool, + /// Track file changes for each capture + #[serde(default)] + pub file_changes: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct FileChangeStats { + pub added: usize, + pub modified: usize, + pub deleted: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -109,9 +119,82 @@ impl CaptureManager { last_user_message: None, user_message_lock: false, clean_on_drop: false, + file_changes: HashMap::new(), }) } + pub fn get_file_changes(&self, tag: &str) -> Result { + let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); + + // Get diff stats against previous tag + let prev_tag = if tag == "0" { + return Ok(FileChangeStats::default()); + } else { + self.get_previous_tag(tag)? + }; + + let output = Command::new("git") + .args([&git_dir_arg, "diff", "--name-status", &prev_tag, tag]) + .output()?; + + if !output.status.success() { + bail!("Failed to get diff stats: {}", String::from_utf8_lossy(&output.stderr)); + } + + let mut stats = FileChangeStats::default(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some(first_char) = line.chars().next() { + match first_char { + 'A' => stats.added += 1, + 'M' => stats.modified += 1, + 'D' => stats.deleted += 1, + _ => {}, + } + } + } + + Ok(stats) + } + + fn get_previous_tag(&self, tag: &str) -> Result { + // Parse tag format "X" or "X.Y" to get previous + if let Ok(turn) = tag.parse::() { + if turn > 0 { + return Ok((turn - 1).to_string()); + } + } else if tag.contains('.') { + let parts: Vec<&str> = tag.split('.').collect(); + if parts.len() == 2 { + if let Ok(tool_num) = parts[1].parse::() { + if tool_num > 1 { + return Ok(format!("{}.{}", parts[0], tool_num - 1)); + } else { + return Ok(parts[0].to_string()); + } + } + } + } + Ok("0".to_string()) + } + + pub fn create_capture_with_stats( + &mut self, + tag: &str, + commit_message: &str, + history_index: usize, + is_turn: bool, + tool_name: Option, + ) -> Result<()> { + self.create_capture(tag, commit_message, history_index, is_turn, tool_name)?; + + // Store file change stats + if let Ok(stats) = self.get_file_changes(tag) { + self.file_changes.insert(tag.to_string(), stats); + } + + Ok(()) + } + pub fn create_capture( &mut self, tag: &str, @@ -199,20 +282,40 @@ impl CaptureManager { Ok(()) } - pub fn diff(&self, tag1: &str, tag2: &str) -> Result { - let _ = self.get_capture(tag1)?; - let _ = self.get_capture(tag2)?; + pub fn diff_detailed(&self, tag1: &str, tag2: &str) -> Result { let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); + let output = Command::new("git") + .args([&git_dir_arg, "diff", "--name-status", tag1, tag2]) + .output()?; + + if !output.status.success() { + bail!("Failed to get diff: {}", String::from_utf8_lossy(&output.stderr)); + } + + let mut result = String::new(); + + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((status, file)) = line.split_once('\t') { + match status { + "A" => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), + "M" => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), + "D" => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), + _ => {}, + } + } + } + let output = Command::new("git") .args([&git_dir_arg, "diff", tag1, tag2, "--stat", "--color=always"]) .output()?; if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - bail!("Failed to get diff: {}", String::from_utf8_lossy(&output.stderr)); + result.push_str("\n"); + result.push_str(&String::from_utf8_lossy(&output.stdout)); } + + Ok(result) } fn get_capture(&self, tag: &str) -> Result<&Capture> { diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 823ebca89b..9bf58bcd4e 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -15,6 +15,7 @@ use eyre::Result; use crate::cli::chat::capture::{ Capture, CaptureManager, + FileChangeStats, }; use crate::cli::chat::{ ChatError, @@ -56,7 +57,11 @@ pub enum CaptureSubcommand { Expand { tag: String }, /// Display a diff between two checkpoints - Diff { tag1: String, tag2: String }, + Diff { + tag1: String, + #[arg(required = false)] + tag2: Option, + }, } impl CaptureSubcommand { @@ -196,9 +201,22 @@ impl CaptureSubcommand { )); }, }, - Self::Diff { tag1, tag2 } => match manager.diff(&tag1, &tag2) { - Ok(diff) => execute!(session.stderr, style::Print(diff))?, - Err(e) => return Err(ChatError::Custom(format!("Could not display diff: {e}").into())), + Self::Diff { tag1, tag2 } => { + // if only provide tag1, compare with current status + let to_tag = tag2.unwrap_or_else(|| "HEAD".to_string()); + + let comparison_text = if to_tag == "HEAD" { + format!("Comparing current state with checkpoint [{}]:\n", tag1) + } else { + format!("Comparing checkpoint [{}] with [{}]:\n", tag1, to_tag) + }; + + match manager.diff_detailed(&tag1, &to_tag) { + Ok(diff) => { + execute!(session.stderr, style::Print(comparison_text.blue()), style::Print(diff))?; + }, + Err(e) => return Err(ChatError::Custom(format!("Could not display diff: {e}").into())), + } }, } @@ -242,6 +260,45 @@ impl TryFrom<&Capture> for CaptureDisplayEntry { } } +impl CaptureDisplayEntry { + fn with_file_stats(capture: &Capture, manager: &CaptureManager) -> Result { + let mut entry = Self::try_from(capture)?; + + if let Some(stats) = manager.file_changes.get(&capture.tag) { + let stats_str = format_file_stats(stats); + if !stats_str.is_empty() { + entry.display_parts.push(format!(" ({})", stats_str).dark_grey()); + } + } + + Ok(entry) + } +} + +fn format_file_stats(stats: &FileChangeStats) -> String { + let mut parts = Vec::new(); + + if stats.added > 0 { + parts.push(format!( + "+{} file{}", + stats.added, + if stats.added == 1 { "" } else { "s" } + )); + } + if stats.modified > 0 { + parts.push(format!("modified {}", stats.modified)); + } + if stats.deleted > 0 { + parts.push(format!( + "-{} file{}", + stats.deleted, + if stats.deleted == 1 { "" } else { "s" } + )); + } + + parts.join(", ") +} + impl std::fmt::Display for CaptureDisplayEntry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for part in self.display_parts.iter() { @@ -265,7 +322,7 @@ fn gather_all_turn_captures(manager: &CaptureManager) -> Result tool.tool.display_name(), }; - match manager.create_capture( + match manager.create_capture_with_stats( &tag, &commit_message, self.conversation.history().len() + 1, @@ -2853,7 +2853,7 @@ impl ChatSession { manager.num_turns += 1; manager.num_tools_this_turn = 0; - match manager.create_capture( + match manager.create_capture_with_stats( &manager.num_turns.to_string(), &truncate_message(&user_message, CAPTURE_MESSAGE_MAX_LENGTH), self.conversation.history().len(), From 3829fa22f33f0c2a62d4561b8dee040b8913a712 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 17 Sep 2025 16:25:10 -0700 Subject: [PATCH 17/31] add the file summary when list and expand --- crates/chat-cli/src/cli/chat/capture.rs | 23 +++++++++ crates/chat-cli/src/cli/chat/cli/capture.rs | 55 +++++++++++++++------ 2 files changed, 64 insertions(+), 14 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 3af753b4f9..2ed9f81014 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -156,6 +156,29 @@ impl CaptureManager { Ok(stats) } + pub fn get_file_changes_between(&self, base: &str, head: &str) -> Result { + let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); + let output = Command::new("git") + .args([&git_dir_arg, "diff", "--name-status", base, head]) + .output()?; + if !output.status.success() { + bail!("Failed to get diff stats: {}", String::from_utf8_lossy(&output.stderr)); + } + let mut stats = FileChangeStats::default(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + // Handle A/M/D and R/C as modified + let code = line.split('\t').next().unwrap_or(""); + match code.chars().next().unwrap_or('M') { + 'A' => stats.added += 1, + 'M' => stats.modified += 1, + 'D' => stats.deleted += 1, + 'R' | 'C' => stats.modified += 1, + _ => {}, + } + } + Ok(stats) + } + fn get_previous_tag(&self, tag: &str) -> Result { // Parse tag format "X" or "X.Y" to get previous if let Ok(turn) = tag.parse::() { diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 9bf58bcd4e..92b06393e1 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -264,13 +264,19 @@ impl CaptureDisplayEntry { fn with_file_stats(capture: &Capture, manager: &CaptureManager) -> Result { let mut entry = Self::try_from(capture)?; - if let Some(stats) = manager.file_changes.get(&capture.tag) { + // Prefer cached stats; if absent, compute on the fly (no mutation of manager needed). + let stats_opt = manager + .file_changes + .get(&capture.tag) + .cloned() + .or_else(|| manager.get_file_changes(&capture.tag).ok()); + + if let Some(stats) = stats_opt.as_ref() { let stats_str = format_file_stats(stats); if !stats_str.is_empty() { entry.display_parts.push(format!(" ({})", stats_str).dark_grey()); } } - Ok(entry) } } @@ -336,29 +342,50 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: String }, }; let capture = &manager.captures[*capture_index]; - let display_entry = CaptureDisplayEntry::with_file_stats(capture, manager)?; + // Turn header: do NOT show file stats here + let display_entry = CaptureDisplayEntry::try_from(capture)?; execute!(output, style::Print(display_entry), style::Print("\n"))?; // If the user tries to expand a tool-level checkpoint, return early if !capture.is_turn { return Ok(()); } else { - let mut display_vec = Vec::new(); + // Collect tool-level entries with their indices so we can diff against the previous capture. + let mut items: Vec<(usize, CaptureDisplayEntry)> = Vec::new(); for i in (0..*capture_index).rev() { - let capture = &manager.captures[i]; - if capture.is_turn { + let c = &manager.captures[i]; + if c.is_turn { break; } - display_vec.push(CaptureDisplayEntry::with_file_stats(&manager.captures[i], manager)?); + items.push((i, CaptureDisplayEntry::try_from(c)?)); } - for entry in display_vec.iter().rev() { - execute!( - output, - style::Print(" └─ ".blue()), - style::Print(entry), - style::Print("\n") - )?; + for (idx, entry) in items.iter().rev() { + // previous capture in creation order (or itself if 0) + let base_idx = idx.saturating_sub(1); + let base_tag = &manager.captures[base_idx].tag; + let curr_tag = &manager.captures[*idx].tag; + // compute stats between previous capture -> this tool capture + let badge = manager + .get_file_changes_between(base_tag, curr_tag) + .map_or_else(|_| String::new(), |s| format_file_stats(&s)); + + if badge.is_empty() { + execute!( + output, + style::Print(" └─ ".blue()), + style::Print(entry), + style::Print("\n") + )?; + } else { + execute!( + output, + style::Print(" └─ ".blue()), + style::Print(entry), + style::Print(format!(" ({})", badge).dark_grey()), + style::Print("\n") + )?; + } } } From 0a6e993193dc7b0504605e2b58e796249ffae2fa Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 17 Sep 2025 17:31:31 -0700 Subject: [PATCH 18/31] revise structure and print no diff msg --- crates/chat-cli/src/cli/chat/capture.rs | 318 +++++++++----------- crates/chat-cli/src/cli/chat/cli/capture.rs | 80 ++++- 2 files changed, 203 insertions(+), 195 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 2ed9f81014..60ef1eff98 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -3,7 +3,10 @@ use std::path::{ Path, PathBuf, }; -use std::process::Command; +use std::process::{ + Command, + Output, +}; use chrono::{ DateTime, @@ -22,15 +25,16 @@ use serde::{ use crate::cli::ConversationState; use crate::os::Os; -// The shadow repo path that MUST be appended with a session-specific directory -// pub const SHADOW_REPO_DIR: &str = "/Users/aws/.amazonq/cli-captures/"; - -// CURRENT APPROACH: -// We only enable automatically enable checkpoints when the user is already in a git repo. -// Otherwise, the user must manually enable checkpoints using `/checkpoint init`. -// This is done so the user is aware that initializing checkpoints outside of a git repo may -// lead to long startup times. +/// CaptureManager manages a session-scoped "shadow" git repository that tracks +/// user workspace changes and snapshots them into tagged checkpoints. +/// The shadow repo is a bare repo; we use `--work-tree=.` to operate on the cwd. +/// +/// Lifecycle: +/// - `auto_init` (preferred when inside a real git repo) or `manual_init` +/// - On each tool use that changes files => stage+commit+tag +/// - `list`/`expand` show checkpoints; `restore` can restore the workspace +/// - If `clean_on_drop` is true (auto init), the session directory is removed on Drop #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureManager { pub shadow_repo_path: PathBuf, @@ -43,7 +47,7 @@ pub struct CaptureManager { /// If true, delete the current session's shadow repo directory when dropped. #[serde(default)] pub clean_on_drop: bool, - /// Track file changes for each capture + /// Track file changes for each capture (cached to avoid repeated git calls). #[serde(default)] pub file_changes: HashMap, } @@ -67,6 +71,8 @@ pub struct Capture { } impl CaptureManager { + /// Auto initialize capture manager when inside a git repo. + /// Auto-initialized sessions are ephemeral (`clean_on_drop = true`). pub async fn auto_init(os: &Os, shadow_path: impl AsRef) -> Result { if !is_git_installed() { bail!("Captures could not be enabled because git is not installed."); @@ -76,24 +82,17 @@ impl CaptureManager { "Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures." ); } - // Reuse bare repo init to keep storage model consistent. let mut s = Self::manual_init(os, shadow_path).await?; - // Auto-initialized captures are considered ephemeral: clean when session ends. s.clean_on_drop = true; Ok(s) } + /// Manual initialization: creates a bare repo and tags the initial capture "0". pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { let path = path.as_ref(); os.fs.create_dir_all(path).await?; - let output = Command::new("git") - .args(["init", "--bare", &path.to_string_lossy()]) - .output()?; - - if !output.status.success() { - bail!("git init failed: {}", String::from_utf8_lossy(&output.stderr)); - } + run_git(path, false, &["init", "--bare", &path.to_string_lossy()])?; config(&path.to_string_lossy())?; stage_commit_tag(&path.to_string_lossy(), "Initial capture", "0")?; @@ -123,55 +122,28 @@ impl CaptureManager { }) } + /// Return diff stats for `tag` vs its previous tag. pub fn get_file_changes(&self, tag: &str) -> Result { - let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); - - // Get diff stats against previous tag - let prev_tag = if tag == "0" { + if tag == "0" { return Ok(FileChangeStats::default()); - } else { - self.get_previous_tag(tag)? - }; - - let output = Command::new("git") - .args([&git_dir_arg, "diff", "--name-status", &prev_tag, tag]) - .output()?; - - if !output.status.success() { - bail!("Failed to get diff stats: {}", String::from_utf8_lossy(&output.stderr)); } - - let mut stats = FileChangeStats::default(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - if let Some(first_char) = line.chars().next() { - match first_char { - 'A' => stats.added += 1, - 'M' => stats.modified += 1, - 'D' => stats.deleted += 1, - _ => {}, - } - } - } - - Ok(stats) + let prev = previous_tag(tag); + self.get_file_changes_between(&prev, tag) } + /// Return diff stats for `base..head` using `git diff --name-status`. pub fn get_file_changes_between(&self, base: &str, head: &str) -> Result { - let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); - let output = Command::new("git") - .args([&git_dir_arg, "diff", "--name-status", base, head]) - .output()?; - if !output.status.success() { - bail!("Failed to get diff stats: {}", String::from_utf8_lossy(&output.stderr)); - } + let out = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", base, head])?; let mut stats = FileChangeStats::default(); - for line in String::from_utf8_lossy(&output.stdout).lines() { - // Handle A/M/D and R/C as modified + + for line in String::from_utf8_lossy(&out.stdout).lines() { + // `--name-status` format: "Xpath", or "R100oldnew" let code = line.split('\t').next().unwrap_or(""); match code.chars().next().unwrap_or('M') { 'A' => stats.added += 1, 'M' => stats.modified += 1, 'D' => stats.deleted += 1, + // Treat R/C (rename/copy) as "modified" for user-facing simplicity 'R' | 'C' => stats.modified += 1, _ => {}, } @@ -179,27 +151,7 @@ impl CaptureManager { Ok(stats) } - fn get_previous_tag(&self, tag: &str) -> Result { - // Parse tag format "X" or "X.Y" to get previous - if let Ok(turn) = tag.parse::() { - if turn > 0 { - return Ok((turn - 1).to_string()); - } - } else if tag.contains('.') { - let parts: Vec<&str> = tag.split('.').collect(); - if parts.len() == 2 { - if let Ok(tool_num) = parts[1].parse::() { - if tool_num > 1 { - return Ok(format!("{}.{}", parts[0], tool_num - 1)); - } else { - return Ok(parts[0].to_string()); - } - } - } - } - Ok("0".to_string()) - } - + /// Stage, commit, tag and record the stats (if possible). pub fn create_capture_with_stats( &mut self, tag: &str, @@ -209,15 +161,13 @@ impl CaptureManager { tool_name: Option, ) -> Result<()> { self.create_capture(tag, commit_message, history_index, is_turn, tool_name)?; - - // Store file change stats if let Ok(stats) = self.get_file_changes(tag) { self.file_changes.insert(tag.to_string(), stats); } - Ok(()) } + /// Stage, commit and tag. Also record in-memory `captures` list. pub fn create_capture( &mut self, tag: &str, @@ -227,7 +177,6 @@ impl CaptureManager { tool_name: Option, ) -> Result<()> { stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), commit_message, tag)?; - self.captures.push(Capture { tag: tag.to_string(), timestamp: Local::now(), @@ -237,59 +186,55 @@ impl CaptureManager { tool_name, }); self.tag_to_index.insert(tag.to_string(), self.captures.len() - 1); - Ok(()) } + /// Restore files from a given tag. + /// - soft: checkout changed tracked files + /// - hard: reset --hard (removes files created since the checkpoint) pub fn restore_capture(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { let capture = self.get_capture(tag)?; - let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); - let output = if !hard { - Command::new("git") - .args([&git_dir_arg, "--work-tree=.", "checkout", tag, "--", "."]) - .output()? + let args = if !hard { + vec!["checkout", tag, "--", "."] } else { - Command::new("git") - .args([&git_dir_arg, "--work-tree=.", "reset", "--hard", tag]) - .output()? + vec!["reset", "--hard", tag] }; - if !output.status.success() { - bail!("git reset failed: {}", String::from_utf8_lossy(&output.stdout)); + // Use --work-tree=. to affect the real workspace + let out = run_git(&self.shadow_repo_path, true, &args)?; + if !out.status.success() { + bail!("git reset failed: {}", String::from_utf8_lossy(&out.stdout)); } + // Trim conversation history back to the point of the capture for _ in capture.history_index..conversation.history().len() { conversation .pop_from_history() .ok_or(eyre!("Tried to pop from empty history"))?; } - Ok(()) } + /// Remove the session's shadow repo directory. pub async fn clean(&self, os: &Os) -> eyre::Result<()> { - // In bare mode, shadow_repo_path is the session directory to delete. let path = &self.shadow_repo_path; - println!("Deleting path: {}", path.display()); if !path.exists() { return Ok(()); } - os.fs.remove_dir_all(path).await?; Ok(()) } - /// Delete the entire captures root (i.e., remove all session captures). - /// This re-creates the empty root directory after deletion. + /// Delete the captures root (all sessions) and recreate it empty. pub async fn clean_all_sessions(&self, os: &Os) -> Result<()> { let root = self .shadow_repo_path .parent() .ok_or_else(|| eyre!("Could not determine captures root"))?; - // Safety guard: ensure last component looks like "cli-captures" + // Safety guard: ensure last component contains "captures" if root .file_name() .and_then(|s| s.to_str()) @@ -301,41 +246,41 @@ impl CaptureManager { println!("Deleting captures root: {}", root.display()); os.fs.remove_dir_all(root).await?; - os.fs.create_dir_all(root).await?; // recreate empty root + os.fs.create_dir_all(root).await?; Ok(()) } + /// Produce a user-friendly diff between two tags, including `--stat`. pub fn diff_detailed(&self, tag1: &str, tag2: &str) -> Result { - let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); - - let output = Command::new("git") - .args([&git_dir_arg, "diff", "--name-status", tag1, tag2]) - .output()?; - - if !output.status.success() { - bail!("Failed to get diff: {}", String::from_utf8_lossy(&output.stderr)); + let out = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", tag1, tag2])?; + if !out.status.success() { + bail!("Failed to get diff: {}", String::from_utf8_lossy(&out.stderr)); } let mut result = String::new(); - - for line in String::from_utf8_lossy(&output.stdout).lines() { + for line in String::from_utf8_lossy(&out.stdout).lines() { if let Some((status, file)) = line.split_once('\t') { - match status { - "A" => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), - "M" => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), - "D" => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), + match status.chars().next().unwrap_or('M') { + 'A' => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), + 'M' => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), + 'D' => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), + // Treat rename/copy as modified for simplicity + 'R' | 'C' => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), _ => {}, } } } - let output = Command::new("git") - .args([&git_dir_arg, "diff", tag1, tag2, "--stat", "--color=always"]) - .output()?; - - if output.status.success() { + let stat = run_git(&self.shadow_repo_path, false, &[ + "diff", + tag1, + tag2, + "--stat", + "--color=always", + ])?; + if stat.status.success() { result.push_str("\n"); - result.push_str(&String::from_utf8_lossy(&output.stdout)); + result.push_str(&String::from_utf8_lossy(&stat.stdout)); } Ok(result) @@ -348,36 +293,33 @@ impl CaptureManager { Ok(&self.captures[*index]) } + /// Whether the real workspace has any tracked uncommitted changes + /// from the perspective of the shadow repo. pub fn has_uncommitted_changes(&self) -> Result { - let git_dir_arg = format!("--git-dir={}", self.shadow_repo_path.display()); - let output = Command::new("git") - .args([&git_dir_arg, "--work-tree=.", "status", "--porcelain"]) - .output()?; - - if !output.status.success() { - bail!("git status failed: {}", String::from_utf8_lossy(&output.stderr)); + let out = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?; + if !out.status.success() { + bail!("git status failed: {}", String::from_utf8_lossy(&out.stderr)); } - Ok(!output.stdout.is_empty()) + Ok(!out.stdout.is_empty()) } } impl Drop for CaptureManager { fn drop(&mut self) { - // Only clean if this session was auto-initialized (ephemeral). if !self.clean_on_drop { return; } let path = self.shadow_repo_path.clone(); + // Prefer spawning on an active Tokio runtime if available. if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { - // Best-effort: swallow errors; we don't want to block Drop or panic here. let _ = tokio::fs::remove_dir_all(path).await; }); return; } - // Fallback: spawn a detached background thread. Still non-blocking. + // Fallback: detached thread. let _ = std::thread::Builder::new() .name("q-capture-cleaner".into()) .spawn(move || { @@ -388,11 +330,11 @@ impl Drop for CaptureManager { pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; +/// Truncate a message on a word boundary (if possible), appending "…". pub fn truncate_message(s: &str, max_chars: usize) -> String { if s.len() <= max_chars { return s.to_string(); } - let truncated = &s[..max_chars]; match truncated.rfind(' ') { Some(pos) => format!("{}...", &truncated[..pos]), @@ -400,11 +342,12 @@ pub fn truncate_message(s: &str, max_chars: usize) -> String { } } +/// Quick checks for git environment presence pub fn is_git_installed() -> bool { Command::new("git") .arg("--version") .output() - .map(|output| output.status.success()) + .map(|o| o.status.success()) .unwrap_or(false) } @@ -412,68 +355,81 @@ pub fn is_in_git_repo() -> bool { Command::new("git") .args(["rev-parse", "--is-inside-work-tree"]) .output() - .map(|output| output.status.success()) + .map(|o| o.status.success()) .unwrap_or(false) } +/// Stage all changes in cwd via the shadow repo, create a commit, and tag it. pub fn stage_commit_tag(shadow_path: &str, commit_message: &str, tag: &str) -> Result<()> { - let git_dir_arg = format!("--git-dir={}", shadow_path); - let output = Command::new("git") - .args([&git_dir_arg, "--work-tree=.", "add", "-A"]) - .output()?; - - if !output.status.success() { - bail!("git add failed: {}", String::from_utf8_lossy(&output.stdout)); + run_git(Path::new(shadow_path), true, &["add", "-A"])?; + + let out = run_git(Path::new(shadow_path), true, &[ + "commit", + "--allow-empty", + "--no-verify", + "-m", + commit_message, + ])?; + if !out.status.success() { + bail!("git commit failed: {}", String::from_utf8_lossy(&out.stdout)); } - let output = Command::new("git") - .args([ - &git_dir_arg, - "--work-tree=.", - "commit", - "--allow-empty", - "--no-verify", - "-m", - commit_message, - ]) - .output()?; - - if !output.status.success() { - bail!("git commit failed: {}", String::from_utf8_lossy(&output.stdout)); - } - - let output = Command::new("git").args([&git_dir_arg, "tag", tag]).output()?; - - if !output.status.success() { - bail!("git tag failed: {}", String::from_utf8_lossy(&output.stdout)); + let out = run_git(Path::new(shadow_path), false, &["tag", tag])?; + if !out.status.success() { + bail!("git tag failed: {}", String::from_utf8_lossy(&out.stdout)); } Ok(()) } +/// Configure the bare repo with a friendly user and preloading for speed. pub fn config(shadow_path: &str) -> Result<()> { - let git_dir_arg = format!("--git-dir={}", shadow_path); - let output = Command::new("git") - .args([&git_dir_arg, "config", "user.name", "Q"]) - .output()?; + run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?; + run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?; + run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?; + Ok(()) +} - if !output.status.success() { - bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); +// ------------------------------ Internal helpers ------------------------------ + +/// Build and run a git command with the session's bare repo. +/// +/// - `with_work_tree = true` adds `--work-tree=.` so git acts on the real workspace. +/// - Always injects `--git-dir=`. +fn run_git(dir: &Path, with_work_tree: bool, args: &[&str]) -> Result { + let git_dir_arg = format!("--git-dir={}", dir.display()); + let mut full_args: Vec = vec![git_dir_arg]; + if with_work_tree { + full_args.push("--work-tree=.".into()); } - - let output = Command::new("git") - .args([&git_dir_arg, "config", "user.email", "qcli@local"]) - .output()?; - - if !output.status.success() { - bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); + full_args.extend(args.iter().map(|s| s.to_string())); + + let out = Command::new("git").args(&full_args).output()?; + if !out.status.success() { + // Keep stderr for diagnosis; many git errors only print to stderr. + let err = String::from_utf8_lossy(&out.stderr).to_string(); + if !err.is_empty() { + bail!(err); + } } + Ok(out) +} - let output = Command::new("git") - .args([&git_dir_arg, "config", "core.preloadindex", "true"]) - .output()?; - - if !output.status.success() { - bail!("git config failed: {}", String::from_utf8_lossy(&output.stdout)); +/// Compute previous tag for a given tag string: +/// - "X" => (X-1) or "0" if X == 0 +/// - "X.Y" => same turn previous tool if Y>1, else "X" +/// - default => "0" +fn previous_tag(tag: &str) -> String { + if let Ok(turn) = tag.parse::() { + return turn.saturating_sub(1).to_string(); } - Ok(()) + if let Some((turn, tool)) = tag.split_once('.') { + if let Ok(tool_num) = tool.parse::() { + return if tool_num > 1 { + format!("{}.{}", turn, tool_num - 1) + } else { + turn.to_string() + }; + } + } + "0".to_string() } diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 92b06393e1..9014d3857c 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -31,22 +31,20 @@ pub enum CaptureSubcommand { Init, /// Revert to a specified checkpoint or the most recent if none specified - // Hard will reset all files and delete files that were created since the - // checkpoint - // Not specifying hard only restores modifications/deletions of tracked files + /// --hard: reset all files and delete any created since the checkpoint Restore { tag: Option, #[arg(long)] hard: bool, }, - /// View all checkpoints + /// View all checkpoints (turn-level only) List { #[arg(short, long)] limit: Option, }, - /// Delete shadow repository + /// Delete shadow repository or the whole captures root (--all) Clean { /// Delete the entire captures root (all sessions) #[arg(long)] @@ -56,7 +54,7 @@ pub enum CaptureSubcommand { /// Display more information about a turn-level checkpoint Expand { tag: String }, - /// Display a diff between two checkpoints + /// Display a diff between two checkpoints (default tag2=HEAD) Diff { tag1: String, #[arg(required = false)] @@ -205,17 +203,60 @@ impl CaptureSubcommand { // if only provide tag1, compare with current status let to_tag = tag2.unwrap_or_else(|| "HEAD".to_string()); + let tag_missing = |t: &str| t != "HEAD" && !manager.tag_to_index.contains_key(t); + if tag_missing(&tag1) { + execute!( + session.stderr, + style::Print( + format!( + "Capture with tag '{}' does not exist! Use /capture list to see available captures\n", + tag1 + ) + .blue() + ) + )?; + session.conversation.capture_manager = Some(manager); + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + if tag_missing(&to_tag) { + execute!( + session.stderr, + style::Print( + format!( + "Capture with tag '{}' does not exist! Use /capture list to see available captures\n", + to_tag + ) + .blue() + ) + )?; + session.conversation.capture_manager = Some(manager); + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + let comparison_text = if to_tag == "HEAD" { format!("Comparing current state with checkpoint [{}]:\n", tag1) } else { format!("Comparing checkpoint [{}] with [{}]:\n", tag1, to_tag) }; - + execute!(session.stderr, style::Print(comparison_text.blue()))?; match manager.diff_detailed(&tag1, &to_tag) { Ok(diff) => { - execute!(session.stderr, style::Print(comparison_text.blue()), style::Print(diff))?; + if diff.trim().is_empty() { + execute!(session.stderr, style::Print("No differences.\n".dark_grey()))?; + } else { + execute!(session.stderr, style::Print(diff))?; + } + }, + Err(e) => { + return { + session.conversation.capture_manager = Some(manager); + Err(ChatError::Custom(format!("Could not display diff: {e}").into())) + }; }, - Err(e) => return Err(ChatError::Custom(format!("Could not display diff: {e}").into())), } }, } @@ -227,6 +268,7 @@ impl CaptureSubcommand { } } +// ------------------------------ formatting helpers ------------------------------ pub struct CaptureDisplayEntry { pub tag: String, pub display_parts: Vec>, @@ -238,11 +280,12 @@ impl TryFrom<&Capture> for CaptureDisplayEntry { fn try_from(value: &Capture) -> std::result::Result { let tag = value.tag.clone(); let mut parts = Vec::new(); + // Keep exact original UX: turn lines start with "[tag] TIMESTAMP - message" + // tool lines start with "[tag] TOOL_NAME: message" + parts.push(format!("[{tag}] ",).blue()); if value.is_turn { - parts.push(format!("[{tag}] ",).blue()); parts.push(format!("{} - {}", value.timestamp.format("%Y-%m-%d %H:%M:%S"), value.message).reset()); } else { - parts.push(format!("[{tag}] ",).blue()); parts.push( format!( "{}: ", @@ -261,10 +304,11 @@ impl TryFrom<&Capture> for CaptureDisplayEntry { } impl CaptureDisplayEntry { + /// Attach cached or computed file stats to a *turn-level* display line. + /// (For `/capture list` we append stats to turn rows only, keeping original UX.) fn with_file_stats(capture: &Capture, manager: &CaptureManager) -> Result { let mut entry = Self::try_from(capture)?; - // Prefer cached stats; if absent, compute on the fly (no mutation of manager needed). let stats_opt = manager .file_changes .get(&capture.tag) @@ -282,8 +326,9 @@ impl CaptureDisplayEntry { } fn format_file_stats(stats: &FileChangeStats) -> String { + // Keep wording to avoid UX drift: + // "+N files, modified M, -K files" let mut parts = Vec::new(); - if stats.added > 0 { parts.push(format!( "+{} file{}", @@ -333,11 +378,18 @@ fn gather_all_turn_captures(manager: &CaptureManager) -> Result Result<()> { let capture_index = match manager.tag_to_index.get(&tag) { Some(i) => i, None => { - execute!(output, style::Print(format!("Checkpoint with tag '{tag}' does not exist! Use /checkpoint list to see available checkpoints\n").blue()))?; + execute!( + output, + style::Print( + format!("Capture with tag '{tag}' does not exist! Use /capture list to see available captures\n") + .blue() + ) + )?; return Ok(()); }, }; From 58ea878a3c2a0c2fcba5a2ad7d08b385a1bd119e Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Thu, 18 Sep 2025 14:55:42 -0700 Subject: [PATCH 19/31] delete all flag, add summry when fs read --- crates/chat-cli/src/cli/chat/capture.rs | 23 --------- crates/chat-cli/src/cli/chat/cli/capture.rs | 55 ++++++++++----------- crates/chat-cli/src/cli/chat/mod.rs | 11 +++-- crates/chat-cli/src/cli/chat/tools/mod.rs | 1 + 4 files changed, 35 insertions(+), 55 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index 60ef1eff98..c69c76f881 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -227,29 +227,6 @@ impl CaptureManager { Ok(()) } - /// Delete the captures root (all sessions) and recreate it empty. - pub async fn clean_all_sessions(&self, os: &Os) -> Result<()> { - let root = self - .shadow_repo_path - .parent() - .ok_or_else(|| eyre!("Could not determine captures root"))?; - - // Safety guard: ensure last component contains "captures" - if root - .file_name() - .and_then(|s| s.to_str()) - .map(|s| s.contains("captures")) - != Some(true) - { - bail!("Refusing to delete unexpected parent directory: {}", root.display()); - } - - println!("Deleting captures root: {}", root.display()); - os.fs.remove_dir_all(root).await?; - os.fs.create_dir_all(root).await?; - Ok(()) - } - /// Produce a user-friendly diff between two tags, including `--stat`. pub fn diff_detailed(&self, tag1: &str, tag2: &str) -> Result { let out = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", tag1, tag2])?; diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 9014d3857c..c99880b756 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -30,10 +30,28 @@ pub enum CaptureSubcommand { /// Manually initialize captures Init, - /// Revert to a specified checkpoint or the most recent if none specified - /// --hard: reset all files and delete any created since the checkpoint + /// Restore workspace to a checkpoint + #[command( + about = "Restore workspace to a checkpoint", + long_about = r#"Restore files to a checkpoint . If is omitted, you'll pick one interactively. + +Default: + • Revert tracked changes and restore tracked deletions. + • Do NOT delete files created after the checkpoint. + +--hard: + • Make workspace exactly match the checkpoint. + • Delete tracked files created after the checkpoint. + +Notes: + • Also rolls back conversation history to the checkpoint. + • Tags: turn (e.g., 3) or tool (e.g., 3.1)."# + )] Restore { + /// Checkpoint tag (e.g., 3 or 3.1). Omit to choose interactively. tag: Option, + + /// Match checkpoint exactly; deletes tracked files created after it. #[arg(long)] hard: bool, }, @@ -44,12 +62,8 @@ pub enum CaptureSubcommand { limit: Option, }, - /// Delete shadow repository or the whole captures root (--all) - Clean { - /// Delete the entire captures root (all sessions) - #[arg(long)] - all: bool, - }, + /// Delete shadow repository + Clean, /// Display more information about a turn-level checkpoint Expand { tag: String }, @@ -157,32 +171,15 @@ impl CaptureSubcommand { return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); }, }, - Self::Clean { all } => { - let res = if all { - manager.clean_all_sessions(os).await - } else { - manager.clean(os).await - }; - match res { + Self::Clean {} => { + match manager.clean(os).await { Ok(()) => execute!( session.stderr, - style::Print( - if all { - "Deleted all session captures under the captures root.\n" - } else { - "Deleted shadow repository for this session.\n" - } - .blue() - .bold() - ) + style::Print("Deleted shadow repository for this session.\n".bold()) )?, Err(e) => { session.conversation.capture_manager = None; - return Err(ChatError::Custom(if all { - format!("Could not delete captures root: {e}").into() - } else { - format!("Could not delete shadow repo: {e}").into() - })); + return Err(ChatError::Custom(format!("Could not delete shadow repo: {e}").into())); }, } session.conversation.capture_manager = None; diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 9c5461ef19..1ed71f9e86 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -2357,9 +2357,14 @@ impl ChatSession { }; let tag = if has_uncommitted { let mut tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn + 1); - let commit_message = match tool.tool.get_summary() { - Some(summary) => summary, - None => tool.tool.display_name(), + let is_fs_read = matches!(&tool.tool, Tool::FsRead(_)); + let commit_message = if is_fs_read { + "External edits detected (likely manual change)".to_string() + } else { + match tool.tool.get_summary() { + Some(summary) => summary, + None => tool.tool.display_name(), + } }; match manager.create_capture_with_stats( diff --git a/crates/chat-cli/src/cli/chat/tools/mod.rs b/crates/chat-cli/src/cli/chat/tools/mod.rs index b75ab8552b..527e0c2025 100644 --- a/crates/chat-cli/src/cli/chat/tools/mod.rs +++ b/crates/chat-cli/src/cli/chat/tools/mod.rs @@ -193,6 +193,7 @@ impl Tool { match self { Tool::FsWrite(fs_write) => fs_write.get_summary().cloned(), Tool::ExecuteCommand(execute_cmd) => execute_cmd.summary.clone(), + Tool::FsRead(fs_read) => fs_read.summary.clone(), _ => None, } } From f1b76f844a027ee7352b00747d7588c7628eaf79 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 14:17:36 -0700 Subject: [PATCH 20/31] refactor code --- crates/chat-cli/src/cli/chat/capture.rs | 430 +++++++------ crates/chat-cli/src/cli/chat/cli/capture.rs | 632 ++++++++++---------- crates/chat-cli/src/cli/chat/mod.rs | 140 +++-- 3 files changed, 606 insertions(+), 596 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index c69c76f881..f56eb2cd8d 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -26,34 +26,41 @@ use serde::{ use crate::cli::ConversationState; use crate::os::Os; -/// CaptureManager manages a session-scoped "shadow" git repository that tracks -/// user workspace changes and snapshots them into tagged checkpoints. -/// The shadow repo is a bare repo; we use `--work-tree=.` to operate on the cwd. -/// -/// Lifecycle: -/// - `auto_init` (preferred when inside a real git repo) or `manual_init` -/// - On each tool use that changes files => stage+commit+tag -/// - `list`/`expand` show checkpoints; `restore` can restore the workspace -/// - If `clean_on_drop` is true (auto init), the session directory is removed on Drop +/// Manages a shadow git repository for tracking and restoring workspace changes #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CaptureManager { + /// Path to the shadow (bare) git repository pub shadow_repo_path: PathBuf, + + /// All captures in chronological order pub captures: Vec, - pub tag_to_index: HashMap, - pub num_turns: usize, - pub num_tools_this_turn: usize, - pub last_user_message: Option, - pub user_message_lock: bool, - /// If true, delete the current session's shadow repo directory when dropped. + + /// Fast lookup: tag -> index in captures vector + pub tag_index: HashMap, + + /// Track the current turn number + pub current_turn: usize, + + /// Track tool uses within current turn + pub tools_in_turn: usize, + + /// Last user message for commit description + pub pending_user_message: Option, + + /// Whether to clean up on drop (for auto-initialized sessions) #[serde(default)] - pub clean_on_drop: bool, - /// Track file changes for each capture (cached to avoid repeated git calls). + pub auto_cleanup: bool, + + /// Whether the message has been locked for this turn + pub message_locked: bool, + + /// Cached file change statistics #[serde(default)] - pub file_changes: HashMap, + pub file_stats_cache: HashMap, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] -pub struct FileChangeStats { +pub struct FileStats { pub added: usize, pub modified: usize, pub deleted: usize, @@ -63,264 +70,253 @@ pub struct FileChangeStats { pub struct Capture { pub tag: String, pub timestamp: DateTime, - pub message: String, + pub description: String, pub history_index: usize, - pub is_turn: bool, pub tool_name: Option, } impl CaptureManager { - /// Auto initialize capture manager when inside a git repo. - /// Auto-initialized sessions are ephemeral (`clean_on_drop = true`). + /// Initialize capture manager automatically (when in a git repo) pub async fn auto_init(os: &Os, shadow_path: impl AsRef) -> Result { if !is_git_installed() { - bail!("Captures could not be enabled because git is not installed."); + bail!("Git is not installed. Captures require git to function."); } if !is_in_git_repo() { - bail!( - "Must be in a git repo for automatic capture initialization. Use /capture init to manually enable captures." - ); + bail!("Not in a git repository. Use '/capture init' to manually enable captures."); } - let mut s = Self::manual_init(os, shadow_path).await?; - s.clean_on_drop = true; - Ok(s) + + let mut manager = Self::manual_init(os, shadow_path).await?; + manager.auto_cleanup = true; + Ok(manager) } - /// Manual initialization: creates a bare repo and tags the initial capture "0". + /// Initialize capture manager manually pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { let path = path.as_ref(); os.fs.create_dir_all(path).await?; + // Initialize bare repository run_git(path, false, &["init", "--bare", &path.to_string_lossy()])?; - config(&path.to_string_lossy())?; - stage_commit_tag(&path.to_string_lossy(), "Initial capture", "0")?; + // Configure git + configure_git(&path.to_string_lossy())?; - let captures = vec![Capture { + // Create initial capture + stage_commit_tag(&path.to_string_lossy(), "Initial state", "0")?; + + let initial_capture = Capture { tag: "0".to_string(), timestamp: Local::now(), - message: "Initial capture".to_string(), + description: "Initial state".to_string(), history_index: 0, is_turn: true, tool_name: None, - }]; + }; - let mut tag_to_index = HashMap::new(); - tag_to_index.insert("0".to_string(), 0); + let mut tag_index = HashMap::new(); + tag_index.insert("0".to_string(), 0); Ok(Self { shadow_repo_path: path.to_path_buf(), - captures, - tag_to_index, - num_turns: 0, - num_tools_this_turn: 0, - last_user_message: None, - user_message_lock: false, - clean_on_drop: false, - file_changes: HashMap::new(), + captures: vec![initial_capture], + tag_index, + current_turn: 0, + tools_in_turn: 0, + pending_user_message: None, + auto_cleanup: false, + message_locked: false, + file_stats_cache: HashMap::new(), }) } - /// Return diff stats for `tag` vs its previous tag. - pub fn get_file_changes(&self, tag: &str) -> Result { - if tag == "0" { - return Ok(FileChangeStats::default()); - } - let prev = previous_tag(tag); - self.get_file_changes_between(&prev, tag) - } - - /// Return diff stats for `base..head` using `git diff --name-status`. - pub fn get_file_changes_between(&self, base: &str, head: &str) -> Result { - let out = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", base, head])?; - let mut stats = FileChangeStats::default(); - - for line in String::from_utf8_lossy(&out.stdout).lines() { - // `--name-status` format: "Xpath", or "R100oldnew" - let code = line.split('\t').next().unwrap_or(""); - match code.chars().next().unwrap_or('M') { - 'A' => stats.added += 1, - 'M' => stats.modified += 1, - 'D' => stats.deleted += 1, - // Treat R/C (rename/copy) as "modified" for user-facing simplicity - 'R' | 'C' => stats.modified += 1, - _ => {}, - } - } - Ok(stats) - } - - /// Stage, commit, tag and record the stats (if possible). - pub fn create_capture_with_stats( - &mut self, - tag: &str, - commit_message: &str, - history_index: usize, - is_turn: bool, - tool_name: Option, - ) -> Result<()> { - self.create_capture(tag, commit_message, history_index, is_turn, tool_name)?; - if let Ok(stats) = self.get_file_changes(tag) { - self.file_changes.insert(tag.to_string(), stats); - } - Ok(()) - } - - /// Stage, commit and tag. Also record in-memory `captures` list. + /// Create a new capture point pub fn create_capture( &mut self, tag: &str, - commit_message: &str, + description: &str, history_index: usize, is_turn: bool, tool_name: Option, ) -> Result<()> { - stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), commit_message, tag)?; - self.captures.push(Capture { + // Stage, commit and tag + stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), description, tag)?; + + // Record capture metadata + let capture = Capture { tag: tag.to_string(), timestamp: Local::now(), - message: commit_message.to_string(), + description: description.to_string(), history_index, is_turn, tool_name, - }); - self.tag_to_index.insert(tag.to_string(), self.captures.len() - 1); + }; + + self.captures.push(capture); + self.tag_index.insert(tag.to_string(), self.captures.len() - 1); + + // Cache file stats for this capture + if let Ok(stats) = self.compute_file_stats(tag) { + self.file_stats_cache.insert(tag.to_string(), stats); + } + Ok(()) } - /// Restore files from a given tag. - /// - soft: checkout changed tracked files - /// - hard: reset --hard (removes files created since the checkpoint) - pub fn restore_capture(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { + /// Restore workspace to a specific capture + pub fn restore(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { let capture = self.get_capture(tag)?; - let args = if !hard { - vec!["checkout", tag, "--", "."] - } else { + + // Restore files + let args = if hard { vec!["reset", "--hard", tag] + } else { + vec!["checkout", tag, "--", "."] }; - // Use --work-tree=. to affect the real workspace - let out = run_git(&self.shadow_repo_path, true, &args)?; - if !out.status.success() { - bail!("git reset failed: {}", String::from_utf8_lossy(&out.stdout)); + let output = run_git(&self.shadow_repo_path, true, &args)?; + if !output.status.success() { + bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); } - // Trim conversation history back to the point of the capture - for _ in capture.history_index..conversation.history().len() { + // Restore conversation history + while conversation.history().len() > capture.history_index { conversation .pop_from_history() - .ok_or(eyre!("Tried to pop from empty history"))?; + .ok_or(eyre!("Failed to restore conversation history"))?; } + Ok(()) } - /// Remove the session's shadow repo directory. - pub async fn clean(&self, os: &Os) -> eyre::Result<()> { - let path = &self.shadow_repo_path; - println!("Deleting path: {}", path.display()); - - if !path.exists() { - return Ok(()); + /// Get file change statistics for a capture + pub fn compute_file_stats(&self, tag: &str) -> Result { + if tag == "0" { + return Ok(FileStats::default()); } - os.fs.remove_dir_all(path).await?; - Ok(()) + + let prev_tag = get_previous_tag(tag); + self.compute_stats_between(&prev_tag, tag) } - /// Produce a user-friendly diff between two tags, including `--stat`. - pub fn diff_detailed(&self, tag1: &str, tag2: &str) -> Result { - let out = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", tag1, tag2])?; - if !out.status.success() { - bail!("Failed to get diff: {}", String::from_utf8_lossy(&out.stderr)); + /// Compute file statistics between two captures + pub fn compute_stats_between(&self, from: &str, to: &str) -> Result { + let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?; + + let mut stats = FileStats::default(); + for line in String::from_utf8_lossy(&output.stdout).lines() { + if let Some((status, _)) = line.split_once('\t') { + match status.chars().next() { + Some('A') => stats.added += 1, + Some('M') => stats.modified += 1, + Some('D') => stats.deleted += 1, + Some('R') | Some('C') => stats.modified += 1, + _ => {}, + } + } } + Ok(stats) + } + + /// Generate detailed diff between captures + pub fn diff(&self, from: &str, to: &str) -> Result { let mut result = String::new(); - for line in String::from_utf8_lossy(&out.stdout).lines() { + + // Get file changes + let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?; + + for line in String::from_utf8_lossy(&output.stdout).lines() { if let Some((status, file)) = line.split_once('\t') { - match status.chars().next().unwrap_or('M') { - 'A' => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), - 'M' => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), - 'D' => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), - // Treat rename/copy as modified for simplicity - 'R' | 'C' => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), + match status.chars().next() { + Some('A') => result.push_str(&format!(" + {} (added)\n", file).green().to_string()), + Some('M') => result.push_str(&format!(" ~ {} (modified)\n", file).yellow().to_string()), + Some('D') => result.push_str(&format!(" - {} (deleted)\n", file).red().to_string()), + Some('R' | 'C') => result.push_str(&format!(" ~ {} (renamed)\n", file).yellow().to_string()), _ => {}, } } } - let stat = run_git(&self.shadow_repo_path, false, &[ + // Add statistics + let stat_output = run_git(&self.shadow_repo_path, false, &[ "diff", - tag1, - tag2, + from, + to, "--stat", "--color=always", ])?; - if stat.status.success() { + + if stat_output.status.success() { result.push_str("\n"); - result.push_str(&String::from_utf8_lossy(&stat.stdout)); + result.push_str(&String::from_utf8_lossy(&stat_output.stdout)); } Ok(result) } - fn get_capture(&self, tag: &str) -> Result<&Capture> { - let Some(index) = self.tag_to_index.get(tag) else { - bail!("No capture with tag {tag}"); - }; - Ok(&self.captures[*index]) + /// Check for uncommitted changes + pub fn has_changes(&self) -> Result { + let output = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?; + Ok(!output.stdout.is_empty()) } - /// Whether the real workspace has any tracked uncommitted changes - /// from the perspective of the shadow repo. - pub fn has_uncommitted_changes(&self) -> Result { - let out = run_git(&self.shadow_repo_path, true, &["status", "--porcelain"])?; - if !out.status.success() { - bail!("git status failed: {}", String::from_utf8_lossy(&out.stderr)); + /// Clean up shadow repository + pub async fn cleanup(&self, os: &Os) -> Result<()> { + if self.shadow_repo_path.exists() { + os.fs.remove_dir_all(&self.shadow_repo_path).await?; } - Ok(!out.stdout.is_empty()) + Ok(()) + } + + fn get_capture(&self, tag: &str) -> Result<&Capture> { + self.tag_index + .get(tag) + .and_then(|&idx| self.captures.get(idx)) + .ok_or_else(|| eyre!("Capture '{}' not found", tag)) } } impl Drop for CaptureManager { fn drop(&mut self) { - if !self.clean_on_drop { + if !self.auto_cleanup { return; } - let path = self.shadow_repo_path.clone(); - // Prefer spawning on an active Tokio runtime if available. + let path = self.shadow_repo_path.clone(); + // Try to spawn cleanup task if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn(async move { let _ = tokio::fs::remove_dir_all(path).await; }); - return; - } - - // Fallback: detached thread. - let _ = std::thread::Builder::new() - .name("q-capture-cleaner".into()) - .spawn(move || { - let _ = std::fs::remove_dir_all(&path); + } else { + // Fallback to thread + std::thread::spawn(move || { + let _ = std::fs::remove_dir_all(path); }); + } } } -pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; +// Helper functions -/// Truncate a message on a word boundary (if possible), appending "…". -pub fn truncate_message(s: &str, max_chars: usize) -> String { - if s.len() <= max_chars { +/// Truncate message for display +pub fn truncate_message(s: &str, max_len: usize) -> String { + if s.len() <= max_len { return s.to_string(); } - let truncated = &s[..max_chars]; - match truncated.rfind(' ') { - Some(pos) => format!("{}...", &truncated[..pos]), - None => format!("{}...", truncated), + + let truncated = &s[..max_len]; + if let Some(pos) = truncated.rfind(' ') { + format!("{}...", &truncated[..pos]) + } else { + format!("{}...", truncated) } } -/// Quick checks for git environment presence -pub fn is_git_installed() -> bool { +pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; + +fn is_git_installed() -> bool { Command::new("git") .arg("--version") .output() @@ -328,7 +324,7 @@ pub fn is_git_installed() -> bool { .unwrap_or(false) } -pub fn is_in_git_repo() -> bool { +fn is_in_git_repo() -> bool { Command::new("git") .args(["rev-parse", "--is-inside-work-tree"]) .output() @@ -336,77 +332,73 @@ pub fn is_in_git_repo() -> bool { .unwrap_or(false) } -/// Stage all changes in cwd via the shadow repo, create a commit, and tag it. -pub fn stage_commit_tag(shadow_path: &str, commit_message: &str, tag: &str) -> Result<()> { +fn configure_git(shadow_path: &str) -> Result<()> { + run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?; + run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?; + run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?; + Ok(()) +} + +fn stage_commit_tag(shadow_path: &str, message: &str, tag: &str) -> Result<()> { + // Stage all changes run_git(Path::new(shadow_path), true, &["add", "-A"])?; - let out = run_git(Path::new(shadow_path), true, &[ + // Commit + let output = run_git(Path::new(shadow_path), true, &[ "commit", "--allow-empty", "--no-verify", "-m", - commit_message, + message, ])?; - if !out.status.success() { - bail!("git commit failed: {}", String::from_utf8_lossy(&out.stdout)); + + if !output.status.success() { + bail!("Git commit failed: {}", String::from_utf8_lossy(&output.stderr)); } - let out = run_git(Path::new(shadow_path), false, &["tag", tag])?; - if !out.status.success() { - bail!("git tag failed: {}", String::from_utf8_lossy(&out.stdout)); + // Tag + let output = run_git(Path::new(shadow_path), false, &["tag", tag])?; + if !output.status.success() { + bail!("Git tag failed: {}", String::from_utf8_lossy(&output.stderr)); } - Ok(()) -} -/// Configure the bare repo with a friendly user and preloading for speed. -pub fn config(shadow_path: &str) -> Result<()> { - run_git(Path::new(shadow_path), false, &["config", "user.name", "Q"])?; - run_git(Path::new(shadow_path), false, &["config", "user.email", "qcli@local"])?; - run_git(Path::new(shadow_path), false, &["config", "core.preloadindex", "true"])?; Ok(()) } -// ------------------------------ Internal helpers ------------------------------ - -/// Build and run a git command with the session's bare repo. -/// -/// - `with_work_tree = true` adds `--work-tree=.` so git acts on the real workspace. -/// - Always injects `--git-dir=`. fn run_git(dir: &Path, with_work_tree: bool, args: &[&str]) -> Result { - let git_dir_arg = format!("--git-dir={}", dir.display()); - let mut full_args: Vec = vec![git_dir_arg]; + let mut cmd = Command::new("git"); + cmd.arg(format!("--git-dir={}", dir.display())); + if with_work_tree { - full_args.push("--work-tree=.".into()); + cmd.arg("--work-tree=."); } - full_args.extend(args.iter().map(|s| s.to_string())); - - let out = Command::new("git").args(&full_args).output()?; - if !out.status.success() { - // Keep stderr for diagnosis; many git errors only print to stderr. - let err = String::from_utf8_lossy(&out.stderr).to_string(); - if !err.is_empty() { - bail!(err); - } + + cmd.args(args); + + let output = cmd.output()?; + if !output.status.success() && !output.stderr.is_empty() { + bail!(String::from_utf8_lossy(&output.stderr).to_string()); } - Ok(out) + + Ok(output) } -/// Compute previous tag for a given tag string: -/// - "X" => (X-1) or "0" if X == 0 -/// - "X.Y" => same turn previous tool if Y>1, else "X" -/// - default => "0" -fn previous_tag(tag: &str) -> String { - if let Ok(turn) = tag.parse::() { - return turn.saturating_sub(1).to_string(); - } - if let Some((turn, tool)) = tag.split_once('.') { - if let Ok(tool_num) = tool.parse::() { +fn get_previous_tag(tag: &str) -> String { + // Parse turn.tool format + if let Some((turn_str, tool_str)) = tag.split_once('.') { + if let Ok(tool_num) = tool_str.parse::() { return if tool_num > 1 { - format!("{}.{}", turn, tool_num - 1) + format!("{}.{}", turn_str, tool_num - 1) } else { - turn.to_string() + turn_str.to_string() }; } } + + // Parse turn-only format + if let Ok(turn) = tag.parse::() { + return turn.saturating_sub(1).to_string(); + } + "0".to_string() } diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index c99880b756..1452c75055 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -10,12 +10,11 @@ use crossterm::{ style, }; use dialoguer::Select; -use eyre::Result; use crate::cli::chat::capture::{ Capture, CaptureManager, - FileChangeStats, + FileStats, }; use crate::cli::chat::{ ChatError, @@ -27,7 +26,7 @@ use crate::util::directories::get_shadow_repo_dir; #[derive(Debug, PartialEq, Subcommand)] pub enum CaptureSubcommand { - /// Manually initialize captures + /// Initialize captures manually Init, /// Restore workspace to a checkpoint @@ -35,42 +34,45 @@ pub enum CaptureSubcommand { about = "Restore workspace to a checkpoint", long_about = r#"Restore files to a checkpoint . If is omitted, you'll pick one interactively. -Default: - • Revert tracked changes and restore tracked deletions. - • Do NOT delete files created after the checkpoint. +Default mode: + • Restores tracked file changes + • Keeps new files created after the checkpoint ---hard: - • Make workspace exactly match the checkpoint. - • Delete tracked files created after the checkpoint. - -Notes: - • Also rolls back conversation history to the checkpoint. - • Tags: turn (e.g., 3) or tool (e.g., 3.1)."# +With --hard: + • Exactly matches the checkpoint state + • Removes files created after the checkpoint"# )] Restore { - /// Checkpoint tag (e.g., 3 or 3.1). Omit to choose interactively. + /// Checkpoint tag (e.g., 3 or 3.1). Leave empty to select interactively. tag: Option, - /// Match checkpoint exactly; deletes tracked files created after it. + /// Exactly match checkpoint state (removes newer files) #[arg(long)] hard: bool, }, - /// View all checkpoints (turn-level only) + /// List all checkpoints List { + /// Limit number of results shown #[arg(short, long)] limit: Option, }, - /// Delete shadow repository + /// Delete the shadow repository Clean, - /// Display more information about a turn-level checkpoint - Expand { tag: String }, + /// Show details of a checkpoint + Expand { + /// Checkpoint tag to expand + tag: String, + }, - /// Display a diff between two checkpoints (default tag2=HEAD) + /// Show differences between checkpoints Diff { + /// First checkpoint tag tag1: String, + + /// Second checkpoint tag (defaults to current state) #[arg(required = false)] tag2: Option, }, @@ -78,372 +80,390 @@ Notes: impl CaptureSubcommand { pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { - if let CaptureSubcommand::Init = self { - if session.conversation.capture_manager.is_some() { - execute!( - session.stderr, - style::Print( - "Captures are already enabled for this session! Use /capture list to see current captures.\n" - .blue() - ) - )?; - } else { - let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) - .map_err(|e| ChatError::Custom(e.to_string().into()))?; - let start = std::time::Instant::now(); - session.conversation.capture_manager = Some( - CaptureManager::manual_init(os, path) - .await - .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?, - ); - execute!( - session.stderr, - style::Print( - format!("Captures are enabled! (took {:.2}s)\n", start.elapsed().as_secs_f32()) - .blue() - .bold() - ) - )?; - } - return Ok(ChatState::PromptUser { - skip_printing_tools: true, - }); + match self { + Self::Init => self.handle_init(os, session).await, + Self::Restore { ref tag, hard } => self.handle_restore(session, tag.clone(), hard).await, + Self::List { limit } => self.handle_list(session, limit), + Self::Clean => self.handle_clean(os, session).await, + Self::Expand { ref tag } => self.handle_expand(session, tag.clone()), + Self::Diff { ref tag1, ref tag2 } => self.handle_diff(session, tag1.clone(), tag2.clone()), + } + } + + async fn handle_init(&self, os: &Os, session: &mut ChatSession) -> Result { + if session.conversation.capture_manager.is_some() { + execute!( + session.stderr, + style::Print("✓ Captures are already enabled for this session\n".blue()) + )?; + } else { + let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) + .map_err(|e| ChatError::Custom(e.to_string().into()))?; + + let start = std::time::Instant::now(); + session.conversation.capture_manager = Some( + CaptureManager::manual_init(os, path) + .await + .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?, + ); + + execute!( + session.stderr, + style::Print( + format!("✓ Captures enabled ({:.2}s)\n", start.elapsed().as_secs_f32()) + .blue() + .bold() + ) + )?; } + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + async fn handle_restore( + &self, + session: &mut ChatSession, + tag: Option, + hard: bool, + ) -> Result { + // Take manager out temporarily to avoid borrow issues let Some(manager) = session.conversation.capture_manager.take() else { execute!( session.stderr, - style::Print("Captures are not enabled for this session\n".blue()) + style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, }); }; - match self { - Self::Init => (), - Self::Restore { tag, hard } => { - let tag = if let Some(tag) = tag { - tag - } else { - // If the user doesn't provide a tag, allow them to fuzzy select a capture - let display_entries = match gather_all_turn_captures(&manager) { - Ok(entries) => entries, - Err(e) => { - session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom(format!("Error getting captures: {e}\n").into())); - }, - }; - if let Some(index) = fuzzy_select_captures(&display_entries, "Select a capture to restore:") { - if index < display_entries.len() { - display_entries[index].tag.clone() - } else { - session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom( - format!("Selecting capture with index {index} failed\n").into(), - )); - } + let tag_result = if let Some(tag) = tag { + Ok(tag) + } else { + // Interactive selection + match gather_turn_captures(&manager) { + Ok(entries) => { + if let Some(idx) = select_capture(&entries, "Select checkpoint to restore:") { + Ok(entries[idx].tag.clone()) } else { - session.conversation.capture_manager = Some(manager); - return Ok(ChatState::PromptUser { - skip_printing_tools: true, - }); + Err(()) } - }; - let result = manager.restore_capture(&mut session.conversation, &tag, hard); - match result { - Ok(_) => { - execute!( - session.stderr, - style::Print(format!("Restored capture: {tag}\n").blue().bold()) - )?; - }, - Err(e) => { - session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom(format!("Could not restore capture: {}", e).into())); - }, - } - }, - Self::List { limit } => match print_turn_captures(&manager, &mut session.stderr, limit) { - Ok(_) => (), + }, Err(e) => { session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom(format!("Could not display all captures: {e}").into())); + return Err(ChatError::Custom(format!("Failed to gather captures: {}", e).into())); }, - }, - Self::Clean {} => { - match manager.clean(os).await { - Ok(()) => execute!( - session.stderr, - style::Print("Deleted shadow repository for this session.\n".bold()) - )?, - Err(e) => { - session.conversation.capture_manager = None; - return Err(ChatError::Custom(format!("Could not delete shadow repo: {e}").into())); - }, - } - session.conversation.capture_manager = None; + } + }; + + let tag = match tag_result { + Ok(tag) => tag, + Err(_) => { + session.conversation.capture_manager = Some(manager); return Ok(ChatState::PromptUser { skip_printing_tools: true, }); }, - Self::Expand { tag } => match expand_capture(&manager, &mut session.stderr, tag.clone()) { - Ok(_) => (), - Err(e) => { - session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom( - format!("Could not expand checkpoint with tag {}: {e}", tag).into(), - )); - }, + }; + + match manager.restore(&mut session.conversation, &tag, hard) { + Ok(_) => { + execute!( + session.stderr, + style::Print(format!("✓ Restored to checkpoint {}\n", tag).blue().bold()) + )?; + session.conversation.capture_manager = Some(manager); }, - Self::Diff { tag1, tag2 } => { - // if only provide tag1, compare with current status - let to_tag = tag2.unwrap_or_else(|| "HEAD".to_string()); - - let tag_missing = |t: &str| t != "HEAD" && !manager.tag_to_index.contains_key(t); - if tag_missing(&tag1) { - execute!( - session.stderr, - style::Print( - format!( - "Capture with tag '{}' does not exist! Use /capture list to see available captures\n", - tag1 - ) - .blue() - ) - )?; - session.conversation.capture_manager = Some(manager); - return Ok(ChatState::PromptUser { - skip_printing_tools: true, - }); - } - if tag_missing(&to_tag) { - execute!( - session.stderr, - style::Print( - format!( - "Capture with tag '{}' does not exist! Use /capture list to see available captures\n", - to_tag - ) - .blue() - ) - )?; - session.conversation.capture_manager = Some(manager); - return Ok(ChatState::PromptUser { - skip_printing_tools: true, - }); - } + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to restore: {}", e).into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + fn handle_list(&self, session: &mut ChatSession, limit: Option) -> Result { + let Some(manager) = session.conversation.capture_manager.as_ref() else { + execute!( + session.stderr, + style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + print_captures(manager, &mut session.stderr, limit) + .map_err(|e| ChatError::Custom(format!("Could not display all captures: {}", e).into()))?; + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + async fn handle_clean(&self, os: &Os, session: &mut ChatSession) -> Result { + let Some(manager) = session.conversation.capture_manager.take() else { + execute!(session.stderr, style::Print("⚠ Captures not enabled.\n".yellow()))?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + match manager.cleanup(os).await { + Ok(()) => { + execute!(session.stderr, style::Print("✓ Shadow repository deleted.\n".bold()))?; + }, + Err(e) => { + session.conversation.capture_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to clean: {e}").into())); + }, + } + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } - let comparison_text = if to_tag == "HEAD" { - format!("Comparing current state with checkpoint [{}]:\n", tag1) + fn handle_expand(&self, session: &mut ChatSession, tag: String) -> Result { + let Some(manager) = session.conversation.capture_manager.as_ref() else { + execute!( + session.stderr, + style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + expand_capture(manager, &mut session.stderr, &tag) + .map_err(|e| ChatError::Custom(format!("Failed to expand capture: {}", e).into()))?; + + Ok(ChatState::PromptUser { + skip_printing_tools: true, + }) + } + + fn handle_diff( + &self, + session: &mut ChatSession, + tag1: String, + tag2: Option, + ) -> Result { + let Some(manager) = session.conversation.capture_manager.as_ref() else { + execute!( + session.stderr, + style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + }; + + let tag2 = tag2.unwrap_or_else(|| "HEAD".to_string()); + + // Validate tags exist + if tag1 != "HEAD" && !manager.tag_index.contains_key(&tag1) { + execute!( + session.stderr, + style::Print(format!("⚠ Checkpoint '{}' not found\n", tag1).yellow()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + if tag2 != "HEAD" && !manager.tag_index.contains_key(&tag2) { + execute!( + session.stderr, + style::Print(format!("⚠ Checkpoint '{}' not found\n", tag2).yellow()) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + + let header = if tag2 == "HEAD" { + format!("Changes since checkpoint {}:\n", tag1) + } else { + format!("Changes from {} to {}:\n", tag1, tag2) + }; + + execute!(session.stderr, style::Print(header.blue()))?; + + match manager.diff(&tag1, &tag2) { + Ok(diff) => { + if diff.trim().is_empty() { + execute!(session.stderr, style::Print("No changes.\n".dark_grey()))?; } else { - format!("Comparing checkpoint [{}] with [{}]:\n", tag1, to_tag) - }; - execute!(session.stderr, style::Print(comparison_text.blue()))?; - match manager.diff_detailed(&tag1, &to_tag) { - Ok(diff) => { - if diff.trim().is_empty() { - execute!(session.stderr, style::Print("No differences.\n".dark_grey()))?; - } else { - execute!(session.stderr, style::Print(diff))?; - } - }, - Err(e) => { - return { - session.conversation.capture_manager = Some(manager); - Err(ChatError::Custom(format!("Could not display diff: {e}").into())) - }; - }, + execute!(session.stderr, style::Print(diff))?; } }, + Err(e) => { + return Err(ChatError::Custom(format!("Failed to generate diff: {e}").into())); + }, } - session.conversation.capture_manager = Some(manager); Ok(ChatState::PromptUser { skip_printing_tools: true, }) } } -// ------------------------------ formatting helpers ------------------------------ -pub struct CaptureDisplayEntry { - pub tag: String, - pub display_parts: Vec>, -} +// Display helpers -impl TryFrom<&Capture> for CaptureDisplayEntry { - type Error = eyre::Report; +struct CaptureDisplay { + tag: String, + parts: Vec>, +} - fn try_from(value: &Capture) -> std::result::Result { - let tag = value.tag.clone(); +impl CaptureDisplay { + fn from_capture(capture: &Capture, manager: &CaptureManager) -> Result { let mut parts = Vec::new(); - // Keep exact original UX: turn lines start with "[tag] TIMESTAMP - message" - // tool lines start with "[tag] TOOL_NAME: message" - parts.push(format!("[{tag}] ",).blue()); - if value.is_turn { - parts.push(format!("{} - {}", value.timestamp.format("%Y-%m-%d %H:%M:%S"), value.message).reset()); - } else { + + // Tag + parts.push(format!("[{}] ", capture.tag).blue()); + + // Content + if capture.is_turn { + // Turn capture: show timestamp and description parts.push( format!( - "{}: ", - value.tool_name.clone().unwrap_or("No tool provided".to_string()) + "{} - {}", + capture.timestamp.format("%Y-%m-%d %H:%M:%S"), + capture.description ) - .magenta(), + .reset(), ); - parts.push(value.message.clone().reset()); + + // Add file stats if available + if let Some(stats) = manager.file_stats_cache.get(&capture.tag) { + let stats_str = format_stats(stats); + if !stats_str.is_empty() { + parts.push(format!(" ({})", stats_str).dark_grey()); + } + } + } else { + // Tool capture: show tool name and description + let tool_name = capture.tool_name.clone().unwrap_or_else(|| "Tool".to_string()); + parts.push(format!("{}: ", tool_name).magenta()); + parts.push(capture.description.clone().reset()); } Ok(Self { - tag, - display_parts: parts, + tag: capture.tag.clone(), + parts, }) } } -impl CaptureDisplayEntry { - /// Attach cached or computed file stats to a *turn-level* display line. - /// (For `/capture list` we append stats to turn rows only, keeping original UX.) - fn with_file_stats(capture: &Capture, manager: &CaptureManager) -> Result { - let mut entry = Self::try_from(capture)?; - - let stats_opt = manager - .file_changes - .get(&capture.tag) - .cloned() - .or_else(|| manager.get_file_changes(&capture.tag).ok()); - - if let Some(stats) = stats_opt.as_ref() { - let stats_str = format_file_stats(stats); - if !stats_str.is_empty() { - entry.display_parts.push(format!(" ({})", stats_str).dark_grey()); - } +impl std::fmt::Display for CaptureDisplay { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for part in &self.parts { + write!(f, "{}", part)?; } - Ok(entry) + Ok(()) } } -fn format_file_stats(stats: &FileChangeStats) -> String { - // Keep wording to avoid UX drift: - // "+N files, modified M, -K files" +fn format_stats(stats: &FileStats) -> String { let mut parts = Vec::new(); + if stats.added > 0 { - parts.push(format!( - "+{} file{}", - stats.added, - if stats.added == 1 { "" } else { "s" } - )); + parts.push(format!("+{}", stats.added)); } if stats.modified > 0 { - parts.push(format!("modified {}", stats.modified)); + parts.push(format!("~{}", stats.modified)); } if stats.deleted > 0 { - parts.push(format!( - "-{} file{}", - stats.deleted, - if stats.deleted == 1 { "" } else { "s" } - )); + parts.push(format!("-{}", stats.deleted)); } - parts.join(", ") + parts.join(" ") } -impl std::fmt::Display for CaptureDisplayEntry { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - for part in self.display_parts.iter() { - write!(f, "{}", part)?; - } - Ok(()) - } +fn gather_turn_captures(manager: &CaptureManager) -> Result, eyre::Report> { + manager + .captures + .iter() + .filter(|c| c.is_turn) + .map(|c| CaptureDisplay::from_capture(c, manager)) + .collect() } -fn print_turn_captures(manager: &CaptureManager, output: &mut impl Write, limit: Option) -> Result<()> { - let display_entries = gather_all_turn_captures(manager)?; - for entry in display_entries.iter().take(limit.unwrap_or(display_entries.len())) { - execute!(output, style::Print(entry), style::Print("\n"))?; - } - Ok(()) -} +fn print_captures(manager: &CaptureManager, output: &mut impl Write, limit: Option) -> Result<(), eyre::Report> { + let entries = gather_turn_captures(manager)?; + let limit = limit.unwrap_or(entries.len()); -fn gather_all_turn_captures(manager: &CaptureManager) -> Result> { - let mut displays = Vec::new(); - for capture in manager.captures.iter() { - if !capture.is_turn { - continue; - } - displays.push(CaptureDisplayEntry::with_file_stats(capture, manager)?); + for entry in entries.iter().take(limit) { + execute!(output, style::Print(&entry), style::Print("\n"))?; } - Ok(displays) + + Ok(()) } -/// Expand a turn-level checkpoint: -fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: String) -> Result<()> { - let capture_index = match manager.tag_to_index.get(&tag) { - Some(i) => i, - None => { - execute!( - output, - style::Print( - format!("Capture with tag '{tag}' does not exist! Use /capture list to see available captures\n") - .blue() - ) - )?; - return Ok(()); - }, +fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) -> Result<(), eyre::Report> { + let Some(&idx) = manager.tag_index.get(tag) else { + execute!( + output, + style::Print(format!("⚠ Checkpoint '{}' not found\n", tag).yellow()) + )?; + return Ok(()); }; - let capture = &manager.captures[*capture_index]; - // Turn header: do NOT show file stats here - let display_entry = CaptureDisplayEntry::try_from(capture)?; - execute!(output, style::Print(display_entry), style::Print("\n"))?; - // If the user tries to expand a tool-level checkpoint, return early + let capture = &manager.captures[idx]; + + // Print main capture + let display = CaptureDisplay::from_capture(capture, manager)?; + execute!(output, style::Print(&display), style::Print("\n"))?; + if !capture.is_turn { return Ok(()); - } else { - // Collect tool-level entries with their indices so we can diff against the previous capture. - let mut items: Vec<(usize, CaptureDisplayEntry)> = Vec::new(); - for i in (0..*capture_index).rev() { - let c = &manager.captures[i]; - if c.is_turn { - break; - } - items.push((i, CaptureDisplayEntry::try_from(c)?)); + } + + // Print tool captures for this turn + let mut tool_captures = Vec::new(); + for i in (0..idx).rev() { + let c = &manager.captures[i]; + if c.is_turn { + break; } + tool_captures.push((i, CaptureDisplay::from_capture(c, manager)?)); + } - for (idx, entry) in items.iter().rev() { - // previous capture in creation order (or itself if 0) - let base_idx = idx.saturating_sub(1); - let base_tag = &manager.captures[base_idx].tag; - let curr_tag = &manager.captures[*idx].tag; - // compute stats between previous capture -> this tool capture - let badge = manager - .get_file_changes_between(base_tag, curr_tag) - .map_or_else(|_| String::new(), |s| format_file_stats(&s)); - - if badge.is_empty() { - execute!( - output, - style::Print(" └─ ".blue()), - style::Print(entry), - style::Print("\n") - )?; - } else { - execute!( - output, - style::Print(" └─ ".blue()), - style::Print(entry), - style::Print(format!(" ({})", badge).dark_grey()), - style::Print("\n") - )?; - } + for (capture_idx, display) in tool_captures.iter().rev() { + // Compute stats for this tool + let curr_tag = &manager.captures[*capture_idx].tag; + let prev_tag = if *capture_idx > 0 { + &manager.captures[capture_idx - 1].tag + } else { + "0" + }; + + let stats_str = manager + .compute_stats_between(prev_tag, curr_tag) + .map(|s| format_stats(&s)) + .unwrap_or_default(); + + execute!(output, style::Print(" └─ ".blue()), style::Print(display))?; + + if !stats_str.is_empty() { + execute!(output, style::Print(format!(" ({})", stats_str).dark_grey()))?; } + + execute!(output, style::Print("\n"))?; } Ok(()) } -fn fuzzy_select_captures(entries: &[CaptureDisplayEntry], prompt_str: &str) -> Option { +fn select_capture(entries: &[CaptureDisplay], prompt: &str) -> Option { Select::with_theme(&crate::util::dialoguer_theme()) - .with_prompt(prompt_str) + .with_prompt(prompt) .items(entries) .report(false) .interact_opt() diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 1ed71f9e86..5c528b4057 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -12,6 +12,7 @@ mod parser; mod prompt; mod prompt_parser; pub mod server_messenger; +use crate::cli::chat::capture::CAPTURE_MESSAGE_MAX_LENGTH; #[cfg(unix)] mod skim_integration; mod token_counter; @@ -144,7 +145,6 @@ use crate::auth::builder_id::is_idc_user; use crate::cli::TodoListState; use crate::cli::agent::Agents; use crate::cli::chat::capture::{ - CAPTURE_MESSAGE_MAX_LENGTH, CaptureManager, truncate_message, }; @@ -2112,13 +2112,13 @@ impl ChatSession { skip_printing_tools: false, }) } else { - // Track this as the last user message for checkpointing - if let Some(mut manager) = self.conversation.capture_manager.take() { - if !manager.user_message_lock { - manager.last_user_message = Some(user_input.clone()); - manager.user_message_lock = true; + // Track the message for capture descriptions, but only if not already set + // This prevents tool approval responses (y/n/t) from overwriting the original message + if let Some(manager) = self.conversation.capture_manager.as_mut() { + if !manager.message_locked && self.pending_tool_index.is_none() { + manager.pending_user_message = Some(user_input.clone()); + manager.message_locked = true; } - self.conversation.capture_manager = Some(manager); } // Check for a pending tool approval if let Some(index) = self.pending_tool_index { @@ -2342,23 +2342,20 @@ impl ChatSession { } execute!(self.stdout, style::Print("\n"))?; - let tag = if invoke_result.is_ok() { + // Handle capture after tool execution - store tag for later display + let capture_tag = if invoke_result.is_ok() { + // Take manager out temporarily to avoid borrow conflicts if let Some(mut manager) = self.conversation.capture_manager.take() { - let has_uncommitted = match manager.has_uncommitted_changes() { - Ok(b) => b, - Err(e) => { - execute!( - self.stderr, - style::Print(format!("Could not check if uncommitted changes exist: {e}\n").blue()) - )?; - execute!(self.stderr, style::Print("Saving anyways...\n".blue()))?; - true - }, - }; - let tag = if has_uncommitted { - let mut tag = format!("{}.{}", manager.num_turns + 1, manager.num_tools_this_turn + 1); + // Check if there are uncommitted changes + let has_changes = manager.has_changes().unwrap_or(true); + + let tag = if has_changes { + // Generate tag for this tool use + let tag = format!("{}.{}", manager.current_turn + 1, manager.tools_in_turn + 1); + + // Get tool summary for commit message let is_fs_read = matches!(&tool.tool, Tool::FsRead(_)); - let commit_message = if is_fs_read { + let description = if is_fs_read { "External edits detected (likely manual change)".to_string() } else { match tool.tool.get_summary() { @@ -2366,24 +2363,24 @@ impl ChatSession { None => tool.tool.display_name(), } }; - - match manager.create_capture_with_stats( - &tag, - &commit_message, - self.conversation.history().len() + 1, - false, - Some(tool.name.clone()), - ) { - Ok(_) => manager.num_tools_this_turn += 1, - Err(e) => { - debug!("{e}"); - tag = String::new(); - }, + // Get history length before putting manager back + let history_len = self.conversation.history().len(); + + // Create capture + if let Err(e) = + manager.create_capture(&tag, &description, history_len + 1, false, Some(tool.name.clone())) + { + debug!("Failed to create tool capture: {}", e); + String::new() + } else { + manager.tools_in_turn += 1; + tag } - tag } else { String::new() }; + + // Put manager back self.conversation.capture_manager = Some(manager); tag } else { @@ -2436,8 +2433,8 @@ impl ChatSession { style::Print(format!(" ● Completed in {}s", tool_time)), style::SetForegroundColor(Color::Reset), )?; - if !tag.is_empty() { - execute!(self.stdout, style::Print(format!(" [{tag}]").blue().bold()))?; + if !capture_tag.is_empty() { + execute!(self.stdout, style::Print(format!(" [{capture_tag}]").blue().bold()))?; } execute!(self.stdout, style::Print("\n\n"))?; @@ -2844,43 +2841,44 @@ impl ChatSession { self.pending_tool_index = None; self.tool_turn_start_time = None; + // Create turn capture if tools were used if let Some(mut manager) = self.conversation.capture_manager.take() { - manager.user_message_lock = false; - let user_message = match &manager.last_user_message { - Some(message) => { - let message = message.clone(); - manager.last_user_message = None; - message - }, - None => "No description provided".to_string(), - }; - if manager.num_tools_this_turn > 0 { - manager.num_turns += 1; - manager.num_tools_this_turn = 0; - - match manager.create_capture_with_stats( - &manager.num_turns.to_string(), - &truncate_message(&user_message, CAPTURE_MESSAGE_MAX_LENGTH), - self.conversation.history().len(), - true, - None, - ) { - Ok(_) => execute!( + if manager.tools_in_turn > 0 { + // Increment turn counter + manager.current_turn += 1; + + // Get user message for description + let description = manager.pending_user_message.take().map_or_else( + || "Turn completed".to_string(), + |msg| truncate_message(&msg, CAPTURE_MESSAGE_MAX_LENGTH), + ); + + // Get history length before putting manager back + let history_len = self.conversation.history().len(); + + // Create turn capture + let tag = manager.current_turn.to_string(); + if let Err(e) = manager.create_capture(&tag, &description, history_len, true, None) { + execute!( self.stderr, - style::Print(style::Print( - format!("Created capture: {}\n\n", manager.num_turns).blue().bold() - )) - )?, - Err(e) => { - execute!( - self.stderr, - style::Print(style::Print( - format!("Could not create automatic capture: {}\n\n", e).blue() - )) - )?; - }, + style::Print(format!("⚠ Could not create capture: {}\n\n", e).yellow()) + )?; + } else { + execute!( + self.stderr, + style::Print(format!("✓ Created checkpoint {}\n\n", tag).blue().bold()) + )?; } + + // Reset for next turn + manager.tools_in_turn = 0; + manager.message_locked = false; // Unlock for next turn + } else { + // Clear pending message even if no tools were used + manager.pending_user_message = None; } + + // Put manager back self.conversation.capture_manager = Some(manager); } From e2c3590c3ff801d01fd1a22854ac82fac30b570a Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 14:51:44 -0700 Subject: [PATCH 21/31] revise ui --- crates/chat-cli/src/cli/chat/cli/capture.rs | 70 ++++++++++++++------- crates/chat-cli/src/cli/chat/mod.rs | 19 ++++-- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 1452c75055..68a04b2f96 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -29,29 +29,29 @@ pub enum CaptureSubcommand { /// Initialize captures manually Init, - /// Restore workspace to a checkpoint + /// Restore workspace to a capture #[command( - about = "Restore workspace to a checkpoint", - long_about = r#"Restore files to a checkpoint . If is omitted, you'll pick one interactively. + about = "Restore workspace to a capture", + long_about = r#"Restore files to a capture . If is omitted, you'll pick one interactively. Default mode: • Restores tracked file changes - • Keeps new files created after the checkpoint + • Keeps new files created after the capture With --hard: - • Exactly matches the checkpoint state - • Removes files created after the checkpoint"# + • Exactly matches the capture state + • Removes files created after the capture"# )] Restore { - /// Checkpoint tag (e.g., 3 or 3.1). Leave empty to select interactively. + /// Capture tag (e.g., 3 or 3.1). Leave empty to select interactively. tag: Option, - /// Exactly match checkpoint state (removes newer files) + /// Exactly match capture state (removes newer files) #[arg(long)] hard: bool, }, - /// List all checkpoints + /// List all captures List { /// Limit number of results shown #[arg(short, long)] @@ -61,18 +61,18 @@ With --hard: /// Delete the shadow repository Clean, - /// Show details of a checkpoint + /// Show details of a capture Expand { - /// Checkpoint tag to expand + /// Capture tag to expand tag: String, }, - /// Show differences between checkpoints + /// Show differences between captures Diff { - /// First checkpoint tag + /// First capture tag tag1: String, - /// Second checkpoint tag (defaults to current state) + /// Second capture tag (defaults to current state) #[arg(required = false)] tag2: Option, }, @@ -94,7 +94,10 @@ impl CaptureSubcommand { if session.conversation.capture_manager.is_some() { execute!( session.stderr, - style::Print("✓ Captures are already enabled for this session\n".blue()) + style::Print( + "✓ Captures are already enabled for this session! Use /capture list to see current captures.\n" + .blue() + ) )?; } else { let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) @@ -110,7 +113,7 @@ impl CaptureSubcommand { execute!( session.stderr, style::Print( - format!("✓ Captures enabled ({:.2}s)\n", start.elapsed().as_secs_f32()) + format!("✓ Captures are enabled! (took {:.2}s)\n", start.elapsed().as_secs_f32()) .blue() .bold() ) @@ -145,7 +148,7 @@ impl CaptureSubcommand { // Interactive selection match gather_turn_captures(&manager) { Ok(entries) => { - if let Some(idx) = select_capture(&entries, "Select checkpoint to restore:") { + if let Some(idx) = select_capture(&entries, "Select capture to restore:") { Ok(entries[idx].tag.clone()) } else { Err(()) @@ -172,7 +175,7 @@ impl CaptureSubcommand { Ok(_) => { execute!( session.stderr, - style::Print(format!("✓ Restored to checkpoint {}\n", tag).blue().bold()) + style::Print(format!("✓ Restored to capture {}\n", tag).blue().bold()) )?; session.conversation.capture_manager = Some(manager); }, @@ -214,9 +217,18 @@ impl CaptureSubcommand { }); }; + // Print the path that will be deleted + execute!( + session.stderr, + style::Print(format!("Deleting: {}\n", manager.shadow_repo_path.display())) + )?; + match manager.cleanup(os).await { Ok(()) => { - execute!(session.stderr, style::Print("✓ Shadow repository deleted.\n".bold()))?; + execute!( + session.stderr, + style::Print("✓ Deleted shadow repository for this session.\n".bold()) + )?; }, Err(e) => { session.conversation.capture_manager = Some(manager); @@ -270,7 +282,13 @@ impl CaptureSubcommand { if tag1 != "HEAD" && !manager.tag_index.contains_key(&tag1) { execute!( session.stderr, - style::Print(format!("⚠ Checkpoint '{}' not found\n", tag1).yellow()) + style::Print( + format!( + "⚠ Capture '{}' not found! Use /capture list to see available captures\n", + tag1 + ) + .yellow() + ) )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -280,7 +298,13 @@ impl CaptureSubcommand { if tag2 != "HEAD" && !manager.tag_index.contains_key(&tag2) { execute!( session.stderr, - style::Print(format!("⚠ Checkpoint '{}' not found\n", tag2).yellow()) + style::Print( + format!( + "⚠ Capture '{}' not found! Use /capture list to see available captures\n", + tag2 + ) + .yellow() + ) )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -288,7 +312,7 @@ impl CaptureSubcommand { } let header = if tag2 == "HEAD" { - format!("Changes since checkpoint {}:\n", tag1) + format!("Changes since capture {}:\n", tag1) } else { format!("Changes from {} to {}:\n", tag1, tag2) }; @@ -410,7 +434,7 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) let Some(&idx) = manager.tag_index.get(tag) else { execute!( output, - style::Print(format!("⚠ Checkpoint '{}' not found\n", tag).yellow()) + style::Print(format!("⚠ capture '{}' not found\n", tag).yellow()) )?; return Ok(()); }; diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 5c528b4057..5f76c7ecf6 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1329,7 +1329,7 @@ impl ChatSession { } } - // Initialize checkpointing if possible + // Initialize capturing if possible let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; let start = std::time::Instant::now(); let capture_manager = match CaptureManager::auto_init(os, &path).await { @@ -2347,8 +2347,17 @@ impl ChatSession { // Take manager out temporarily to avoid borrow conflicts if let Some(mut manager) = self.conversation.capture_manager.take() { // Check if there are uncommitted changes - let has_changes = manager.has_changes().unwrap_or(true); - + let has_changes = match manager.has_changes() { + Ok(b) => b, + Err(e) => { + execute!( + self.stderr, + style::Print(format!("Could not check if uncommitted changes exist: {e}\n").yellow()) + )?; + execute!(self.stderr, style::Print("Saving anyways...\n".yellow()))?; + true + }, + }; let tag = if has_changes { // Generate tag for this tool use let tag = format!("{}.{}", manager.current_turn + 1, manager.tools_in_turn + 1); @@ -2861,12 +2870,12 @@ impl ChatSession { if let Err(e) = manager.create_capture(&tag, &description, history_len, true, None) { execute!( self.stderr, - style::Print(format!("⚠ Could not create capture: {}\n\n", e).yellow()) + style::Print(format!("⚠ Could not create automatic capture: {}\n\n", e).yellow()) )?; } else { execute!( self.stderr, - style::Print(format!("✓ Created checkpoint {}\n\n", tag).blue().bold()) + style::Print(format!("✓ Created capture {}\n\n", tag).blue().bold()) )?; } From f1f08163fd57d2c7e9315578f2816890ed0a19b0 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 15:40:05 -0700 Subject: [PATCH 22/31] add capture into experiement --- crates/chat-cli/src/cli/chat/capture.rs | 12 +- crates/chat-cli/src/cli/chat/cli/capture.rs | 125 ++++++++++++---- .../chat-cli/src/cli/chat/cli/experiment.rs | 5 + crates/chat-cli/src/cli/chat/mod.rs | 141 ++++++++++-------- crates/chat-cli/src/cli/chat/prompt.rs | 6 - crates/chat-cli/src/database/settings.rs | 8 + 6 files changed, 185 insertions(+), 112 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index f56eb2cd8d..d9904fc430 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -47,10 +47,6 @@ pub struct CaptureManager { /// Last user message for commit description pub pending_user_message: Option, - /// Whether to clean up on drop (for auto-initialized sessions) - #[serde(default)] - pub auto_cleanup: bool, - /// Whether the message has been locked for this turn pub message_locked: bool, @@ -86,8 +82,7 @@ impl CaptureManager { bail!("Not in a git repository. Use '/capture init' to manually enable captures."); } - let mut manager = Self::manual_init(os, shadow_path).await?; - manager.auto_cleanup = true; + let manager = Self::manual_init(os, shadow_path).await?; Ok(manager) } @@ -124,7 +119,6 @@ impl CaptureManager { current_turn: 0, tools_in_turn: 0, pending_user_message: None, - auto_cleanup: false, message_locked: false, file_stats_cache: HashMap::new(), }) @@ -279,10 +273,6 @@ impl CaptureManager { impl Drop for CaptureManager { fn drop(&mut self) { - if !self.auto_cleanup { - return; - } - let path = self.shadow_repo_path.clone(); // Try to spawn cleanup task if let Ok(handle) = tokio::runtime::Handle::try_current() { diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index 68a04b2f96..e0c6397594 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -2,6 +2,8 @@ use std::io::Write; use clap::Subcommand; use crossterm::style::{ + Attribute, + Color, StyledContent, Stylize, }; @@ -21,6 +23,7 @@ use crate::cli::chat::{ ChatSession, ChatState, }; +use crate::database::settings::Setting; use crate::os::Os; use crate::util::directories::get_shadow_repo_dir; @@ -80,6 +83,18 @@ With --hard: impl CaptureSubcommand { pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { + // Check if capture is enabled + if !os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + execute!( + session.stderr, + style::SetForegroundColor(Color::Red), + style::Print("\nCapture is disabled. Enable it with: q settings chat.enableCapture true\n"), + style::SetForegroundColor(Color::Reset) + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } match self { Self::Init => self.handle_init(os, session).await, Self::Restore { ref tag, hard } => self.handle_restore(session, tag.clone(), hard).await, @@ -94,10 +109,11 @@ impl CaptureSubcommand { if session.conversation.capture_manager.is_some() { execute!( session.stderr, + style::SetForegroundColor(Color::Blue), style::Print( "✓ Captures are already enabled for this session! Use /capture list to see current captures.\n" - .blue() - ) + ), + style::SetForegroundColor(Color::Reset) )?; } else { let path = get_shadow_repo_dir(os, session.conversation.conversation_id().to_string()) @@ -112,11 +128,14 @@ impl CaptureSubcommand { execute!( session.stderr, - style::Print( - format!("✓ Captures are enabled! (took {:.2}s)\n", start.elapsed().as_secs_f32()) - .blue() - .bold() - ) + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!( + "✓ Captures are enabled! (took {:.2}s)\n", + start.elapsed().as_secs_f32() + )), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), )?; } @@ -135,7 +154,9 @@ impl CaptureSubcommand { let Some(manager) = session.conversation.capture_manager.take() else { execute!( session.stderr, - style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -175,7 +196,11 @@ impl CaptureSubcommand { Ok(_) => { execute!( session.stderr, - style::Print(format!("✓ Restored to capture {}\n", tag).blue().bold()) + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!("✓ Restored to capture {}\n", tag)), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), )?; session.conversation.capture_manager = Some(manager); }, @@ -194,7 +219,9 @@ impl CaptureSubcommand { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, - style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -211,7 +238,12 @@ impl CaptureSubcommand { async fn handle_clean(&self, os: &Os, session: &mut ChatSession) -> Result { let Some(manager) = session.conversation.capture_manager.take() else { - execute!(session.stderr, style::Print("⚠ Captures not enabled.\n".yellow()))?; + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ ️Captures not enabled.\n"), + style::SetForegroundColor(Color::Reset), + )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, }); @@ -227,7 +259,9 @@ impl CaptureSubcommand { Ok(()) => { execute!( session.stderr, - style::Print("✓ Deleted shadow repository for this session.\n".bold()) + style::SetAttribute(Attribute::Bold), + style::Print("✓ Deleted shadow repository for this session.\n"), + style::SetAttribute(Attribute::Reset), )?; }, Err(e) => { @@ -245,7 +279,9 @@ impl CaptureSubcommand { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, - style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ ️Captures not enabled. Use '/capture init' to enable.\n"), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -269,7 +305,9 @@ impl CaptureSubcommand { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, - style::Print("⚠ Captures not enabled. Use '/capture init' to enable.\n".yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -282,13 +320,12 @@ impl CaptureSubcommand { if tag1 != "HEAD" && !manager.tag_index.contains_key(&tag1) { execute!( session.stderr, - style::Print( - format!( - "⚠ Capture '{}' not found! Use /capture list to see available captures\n", - tag1 - ) - .yellow() - ) + style::SetForegroundColor(Color::Yellow), + style::Print(format!( + "⚠️ Capture '{}' not found! Use /capture list to see available captures\n", + tag1 + )), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -298,13 +335,12 @@ impl CaptureSubcommand { if tag2 != "HEAD" && !manager.tag_index.contains_key(&tag2) { execute!( session.stderr, - style::Print( - format!( - "⚠ Capture '{}' not found! Use /capture list to see available captures\n", - tag2 - ) - .yellow() - ) + style::SetForegroundColor(Color::Yellow), + style::Print(format!( + "⚠️ Capture '{}' not found! Use /capture list to see available captures\n", + tag2 + )), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -317,12 +353,22 @@ impl CaptureSubcommand { format!("Changes from {} to {}:\n", tag1, tag2) }; - execute!(session.stderr, style::Print(header.blue()))?; + execute!( + session.stderr, + style::SetForegroundColor(Color::Blue), + style::Print(header), + style::SetForegroundColor(Color::Reset), + )?; match manager.diff(&tag1, &tag2) { Ok(diff) => { if diff.trim().is_empty() { - execute!(session.stderr, style::Print("No changes.\n".dark_grey()))?; + execute!( + session.stderr, + style::SetForegroundColor(Color::DarkGrey), + style::Print("No changes.\n"), + style::SetForegroundColor(Color::Reset), + )?; } else { execute!(session.stderr, style::Print(diff))?; } @@ -434,7 +480,9 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) let Some(&idx) = manager.tag_index.get(tag) else { execute!( output, - style::Print(format!("⚠ capture '{}' not found\n", tag).yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print(format!("⚠️ capture '{}' not found\n", tag)), + style::SetForegroundColor(Color::Reset), )?; return Ok(()); }; @@ -473,10 +521,21 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) .map(|s| format_stats(&s)) .unwrap_or_default(); - execute!(output, style::Print(" └─ ".blue()), style::Print(display))?; + execute!( + output, + style::SetForegroundColor(Color::Blue), + style::Print(" └─ "), + style::Print(display), + style::SetForegroundColor(Color::Reset), + )?; if !stats_str.is_empty() { - execute!(output, style::Print(format!(" ({})", stats_str).dark_grey()))?; + execute!( + output, + style::SetForegroundColor(Color::DarkGrey), + style::Print(format!(" ({})", stats_str)), + style::SetForegroundColor(Color::Reset), + )?; } execute!(output, style::Print("\n"))?; diff --git a/crates/chat-cli/src/cli/chat/cli/experiment.rs b/crates/chat-cli/src/cli/chat/cli/experiment.rs index 0dd981d9d3..0f6784d930 100644 --- a/crates/chat-cli/src/cli/chat/cli/experiment.rs +++ b/crates/chat-cli/src/cli/chat/cli/experiment.rs @@ -50,6 +50,11 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[ description: "Enables Q to create todo lists that can be viewed and managed using /todos", setting_key: Setting::EnabledTodoList, }, + Experiment { + name: "Capture", + description: "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/capture)", + setting_key: Setting::EnabledCapture, + }, ]; #[derive(Debug, PartialEq, Args)] diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 5f76c7ecf6..10d51e57e8 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1330,26 +1330,28 @@ impl ChatSession { } // Initialize capturing if possible - let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; - let start = std::time::Instant::now(); - let capture_manager = match CaptureManager::auto_init(os, &path).await { - Ok(manager) => { - execute!( - self.stderr, - style::Print( - format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) - .blue() - .bold() - ) - )?; - Some(manager) - }, - Err(e) => { - execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; - None - }, - }; - self.conversation.capture_manager = capture_manager; + if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; + let start = std::time::Instant::now(); + let capture_manager = match CaptureManager::auto_init(os, &path).await { + Ok(manager) => { + execute!( + self.stderr, + style::Print( + format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) + .blue() + .bold() + ) + )?; + Some(manager) + }, + Err(e) => { + execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; + None + }, + }; + self.conversation.capture_manager = capture_manager; + } if let Some(user_input) = self.initial_input.take() { self.inner = Some(ChatState::HandleInput { input: user_input }); @@ -2114,12 +2116,15 @@ impl ChatSession { } else { // Track the message for capture descriptions, but only if not already set // This prevents tool approval responses (y/n/t) from overwriting the original message - if let Some(manager) = self.conversation.capture_manager.as_mut() { - if !manager.message_locked && self.pending_tool_index.is_none() { - manager.pending_user_message = Some(user_input.clone()); - manager.message_locked = true; + if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + if let Some(manager) = self.conversation.capture_manager.as_mut() { + if !manager.message_locked && self.pending_tool_index.is_none() { + manager.pending_user_message = Some(user_input.clone()); + manager.message_locked = true; + } } } + // Check for a pending tool approval if let Some(index) = self.pending_tool_index { let is_trust = ["t", "T"].contains(&input); @@ -2343,18 +2348,24 @@ impl ChatSession { execute!(self.stdout, style::Print("\n"))?; // Handle capture after tool execution - store tag for later display - let capture_tag = if invoke_result.is_ok() { + let capture_tag = { + let enabled = os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false); + if invoke_result.is_err() || !enabled { + String::new() + } // Take manager out temporarily to avoid borrow conflicts - if let Some(mut manager) = self.conversation.capture_manager.take() { + else if let Some(mut manager) = self.conversation.capture_manager.take() { // Check if there are uncommitted changes let has_changes = match manager.has_changes() { Ok(b) => b, Err(e) => { execute!( self.stderr, - style::Print(format!("Could not check if uncommitted changes exist: {e}\n").yellow()) + style::SetForegroundColor(Color::Yellow), + style::Print(format!("Could not check if uncommitted changes exist: {e}\n")), + style::Print("Saving anyways...\n"), + style::SetForegroundColor(Color::Reset), )?; - execute!(self.stderr, style::Print("Saving anyways...\n".yellow()))?; true }, }; @@ -2395,8 +2406,6 @@ impl ChatSession { } else { String::new() } - } else { - String::new() }; let tool_end_time = Instant::now(); @@ -2851,44 +2860,52 @@ impl ChatSession { self.tool_turn_start_time = None; // Create turn capture if tools were used - if let Some(mut manager) = self.conversation.capture_manager.take() { - if manager.tools_in_turn > 0 { - // Increment turn counter - manager.current_turn += 1; - - // Get user message for description - let description = manager.pending_user_message.take().map_or_else( - || "Turn completed".to_string(), - |msg| truncate_message(&msg, CAPTURE_MESSAGE_MAX_LENGTH), - ); + if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + if let Some(mut manager) = self.conversation.capture_manager.take() { + if manager.tools_in_turn > 0 { + // Increment turn counter + manager.current_turn += 1; + + // Get user message for description + let description = manager.pending_user_message.take().map_or_else( + || "Turn completed".to_string(), + |msg| truncate_message(&msg, CAPTURE_MESSAGE_MAX_LENGTH), + ); - // Get history length before putting manager back - let history_len = self.conversation.history().len(); + // Get history length before putting manager back + let history_len = self.conversation.history().len(); - // Create turn capture - let tag = manager.current_turn.to_string(); - if let Err(e) = manager.create_capture(&tag, &description, history_len, true, None) { - execute!( - self.stderr, - style::Print(format!("⚠ Could not create automatic capture: {}\n\n", e).yellow()) - )?; + // Create turn capture + let tag = manager.current_turn.to_string(); + if let Err(e) = manager.create_capture(&tag, &description, history_len, true, None) { + execute!( + self.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print(format!("⚠️ Could not create automatic capture: {}\n\n", e)), + style::SetForegroundColor(Color::Reset), + )?; + } else { + execute!( + self.stderr, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!("✓ Created capture {}\n\n", tag)), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; + } + + // Reset for next turn + manager.tools_in_turn = 0; + manager.message_locked = false; // Unlock for next turn } else { - execute!( - self.stderr, - style::Print(format!("✓ Created capture {}\n\n", tag).blue().bold()) - )?; + // Clear pending message even if no tools were used + manager.pending_user_message = None; } - // Reset for next turn - manager.tools_in_turn = 0; - manager.message_locked = false; // Unlock for next turn - } else { - // Clear pending message even if no tools were used - manager.pending_user_message = None; + // Put manager back + self.conversation.capture_manager = Some(manager); } - - // Put manager back - self.conversation.capture_manager = Some(manager); } self.send_chat_telemetry(os, TelemetryResult::Succeeded, None, None, None, true) diff --git a/crates/chat-cli/src/cli/chat/prompt.rs b/crates/chat-cli/src/cli/chat/prompt.rs index 04eb615147..e7addd8f32 100644 --- a/crates/chat-cli/src/cli/chat/prompt.rs +++ b/crates/chat-cli/src/cli/chat/prompt.rs @@ -96,12 +96,6 @@ pub const COMMANDS: &[&str] = &[ "/save", "/load", "/subscribe", - "/capture init", - "/capture restore", - "/capture list", - "/capture clean", - "/capture expand", - "/capture diff", "/todos", "/todos resume", "/todos clear-finished", diff --git a/crates/chat-cli/src/database/settings.rs b/crates/chat-cli/src/database/settings.rs index 21e8e98097..69c293efdc 100644 --- a/crates/chat-cli/src/database/settings.rs +++ b/crates/chat-cli/src/database/settings.rs @@ -77,6 +77,8 @@ pub enum Setting { ChatEnableHistoryHints, #[strum(message = "Enable the todo list feature (boolean)")] EnabledTodoList, + #[strum(message = "Enable the capture feature (boolean)")] + EnabledCapture, } impl AsRef for Setting { @@ -112,6 +114,7 @@ impl AsRef for Setting { Self::ChatDisableAutoCompaction => "chat.disableAutoCompaction", Self::ChatEnableHistoryHints => "chat.enableHistoryHints", Self::EnabledTodoList => "chat.enableTodoList", + Self::EnabledCapture => "chat.enableCapture", } } } @@ -157,6 +160,7 @@ impl TryFrom<&str> for Setting { "chat.disableAutoCompaction" => Ok(Self::ChatDisableAutoCompaction), "chat.enableHistoryHints" => Ok(Self::ChatEnableHistoryHints), "chat.enableTodoList" => Ok(Self::EnabledTodoList), + "chat.enableCapture" => Ok(Self::EnabledCapture), _ => Err(DatabaseError::InvalidSetting(value.to_string())), } } @@ -293,6 +297,7 @@ mod test { .set(Setting::ChatDisableMarkdownRendering, false) .await .unwrap(); + settings.set(Setting::EnabledCapture, true).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), Some(&Value::Bool(true))); assert_eq!( @@ -316,6 +321,7 @@ mod test { settings.get(Setting::ChatDisableMarkdownRendering), Some(&Value::Bool(false)) ); + assert_eq!(settings.get(Setting::EnabledCapture), Some(&Value::Bool(true))); settings.remove(Setting::TelemetryEnabled).await.unwrap(); settings.remove(Setting::OldClientId).await.unwrap(); @@ -323,6 +329,7 @@ mod test { settings.remove(Setting::KnowledgeIndexType).await.unwrap(); settings.remove(Setting::McpLoadedBefore).await.unwrap(); settings.remove(Setting::ChatDisableMarkdownRendering).await.unwrap(); + settings.remove(Setting::EnabledCapture).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), None); assert_eq!(settings.get(Setting::OldClientId), None); @@ -330,5 +337,6 @@ mod test { assert_eq!(settings.get(Setting::KnowledgeIndexType), None); assert_eq!(settings.get(Setting::McpLoadedBefore), None); assert_eq!(settings.get(Setting::ChatDisableMarkdownRendering), None); + assert_eq!(settings.get(Setting::EnabledCapture), None); } } From 1c366e4a745f8e4ec1c6b8e53c8b5be2c23180c5 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 15:49:38 -0700 Subject: [PATCH 23/31] clippy --- crates/chat-cli/src/cli/chat/capture.rs | 4 ++-- crates/chat-cli/src/cli/chat/cli/capture.rs | 17 ++++++----------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/capture.rs index d9904fc430..a32b3db844 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/capture.rs @@ -204,7 +204,7 @@ impl CaptureManager { Some('A') => stats.added += 1, Some('M') => stats.modified += 1, Some('D') => stats.deleted += 1, - Some('R') | Some('C') => stats.modified += 1, + Some('R' | 'C') => stats.modified += 1, _ => {}, } } @@ -242,7 +242,7 @@ impl CaptureManager { ])?; if stat_output.status.success() { - result.push_str("\n"); + result.push('\n'); result.push_str(&String::from_utf8_lossy(&stat_output.stdout)); } diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/capture.rs index e0c6397594..eaa1cd11a9 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/capture.rs @@ -98,10 +98,10 @@ impl CaptureSubcommand { match self { Self::Init => self.handle_init(os, session).await, Self::Restore { ref tag, hard } => self.handle_restore(session, tag.clone(), hard).await, - Self::List { limit } => self.handle_list(session, limit), + Self::List { limit } => Self::handle_list(session, limit), Self::Clean => self.handle_clean(os, session).await, - Self::Expand { ref tag } => self.handle_expand(session, tag.clone()), - Self::Diff { ref tag1, ref tag2 } => self.handle_diff(session, tag1.clone(), tag2.clone()), + Self::Expand { ref tag } => Self::handle_expand(session, tag.clone()), + Self::Diff { ref tag1, ref tag2 } => Self::handle_diff(session, tag1.clone(), tag2.clone()), } } @@ -215,7 +215,7 @@ impl CaptureSubcommand { }) } - fn handle_list(&self, session: &mut ChatSession, limit: Option) -> Result { + fn handle_list(session: &mut ChatSession, limit: Option) -> Result { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, @@ -275,7 +275,7 @@ impl CaptureSubcommand { }) } - fn handle_expand(&self, session: &mut ChatSession, tag: String) -> Result { + fn handle_expand(session: &mut ChatSession, tag: String) -> Result { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, @@ -296,12 +296,7 @@ impl CaptureSubcommand { }) } - fn handle_diff( - &self, - session: &mut ChatSession, - tag1: String, - tag2: Option, - ) -> Result { + fn handle_diff(session: &mut ChatSession, tag1: String, tag2: Option) -> Result { let Some(manager) = session.conversation.capture_manager.as_ref() else { execute!( session.stderr, From 594305b6cde1567cb3b9b0507d559959d9937d76 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 16:32:02 -0700 Subject: [PATCH 24/31] rename to checkpoint --- .../cli/chat/{capture.rs => checkpoint.rs} | 62 +++--- .../chat/cli/{capture.rs => checkpoint.rs} | 187 +++++++++--------- .../chat-cli/src/cli/chat/cli/experiment.rs | 6 +- crates/chat-cli/src/cli/chat/cli/mod.rs | 10 +- crates/chat-cli/src/cli/chat/conversation.rs | 8 +- crates/chat-cli/src/cli/chat/mod.rs | 94 +++++---- crates/chat-cli/src/database/settings.rs | 16 +- crates/chat-cli/src/util/directories.rs | 2 +- 8 files changed, 210 insertions(+), 175 deletions(-) rename crates/chat-cli/src/cli/chat/{capture.rs => checkpoint.rs} (86%) rename crates/chat-cli/src/cli/chat/cli/{capture.rs => checkpoint.rs} (68%) diff --git a/crates/chat-cli/src/cli/chat/capture.rs b/crates/chat-cli/src/cli/chat/checkpoint.rs similarity index 86% rename from crates/chat-cli/src/cli/chat/capture.rs rename to crates/chat-cli/src/cli/chat/checkpoint.rs index a32b3db844..363da0548d 100644 --- a/crates/chat-cli/src/cli/chat/capture.rs +++ b/crates/chat-cli/src/cli/chat/checkpoint.rs @@ -28,14 +28,14 @@ use crate::os::Os; /// Manages a shadow git repository for tracking and restoring workspace changes #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CaptureManager { +pub struct CheckpointManager { /// Path to the shadow (bare) git repository pub shadow_repo_path: PathBuf, - /// All captures in chronological order - pub captures: Vec, + /// All checkpoints in chronological order + pub checkpoints: Vec, - /// Fast lookup: tag -> index in captures vector + /// Fast lookup: tag -> index in checkpoints vector pub tag_index: HashMap, /// Track the current turn number @@ -63,7 +63,7 @@ pub struct FileStats { } #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Capture { +pub struct Checkpoint { pub tag: String, pub timestamp: DateTime, pub description: String, @@ -72,21 +72,21 @@ pub struct Capture { pub tool_name: Option, } -impl CaptureManager { - /// Initialize capture manager automatically (when in a git repo) +impl CheckpointManager { + /// Initialize checkpoint manager automatically (when in a git repo) pub async fn auto_init(os: &Os, shadow_path: impl AsRef) -> Result { if !is_git_installed() { - bail!("Git is not installed. Captures require git to function."); + bail!("Git is not installed. Checkpoints require git to function."); } if !is_in_git_repo() { - bail!("Not in a git repository. Use '/capture init' to manually enable captures."); + bail!("Not in a git repository. Use '/checkpoint init' to manually enable checkpoints."); } let manager = Self::manual_init(os, shadow_path).await?; Ok(manager) } - /// Initialize capture manager manually + /// Initialize checkpoint manager manually pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { let path = path.as_ref(); os.fs.create_dir_all(path).await?; @@ -97,10 +97,10 @@ impl CaptureManager { // Configure git configure_git(&path.to_string_lossy())?; - // Create initial capture + // Create initial checkpoint stage_commit_tag(&path.to_string_lossy(), "Initial state", "0")?; - let initial_capture = Capture { + let initial_checkpoint = Checkpoint { tag: "0".to_string(), timestamp: Local::now(), description: "Initial state".to_string(), @@ -114,7 +114,7 @@ impl CaptureManager { Ok(Self { shadow_repo_path: path.to_path_buf(), - captures: vec![initial_capture], + checkpoints: vec![initial_checkpoint], tag_index, current_turn: 0, tools_in_turn: 0, @@ -124,8 +124,8 @@ impl CaptureManager { }) } - /// Create a new capture point - pub fn create_capture( + /// Create a new checkpoint point + pub fn create_checkpoint( &mut self, tag: &str, description: &str, @@ -136,8 +136,8 @@ impl CaptureManager { // Stage, commit and tag stage_commit_tag(&self.shadow_repo_path.to_string_lossy(), description, tag)?; - // Record capture metadata - let capture = Capture { + // Record checkpoint metadata + let checkpoint = Checkpoint { tag: tag.to_string(), timestamp: Local::now(), description: description.to_string(), @@ -146,10 +146,10 @@ impl CaptureManager { tool_name, }; - self.captures.push(capture); - self.tag_index.insert(tag.to_string(), self.captures.len() - 1); + self.checkpoints.push(checkpoint); + self.tag_index.insert(tag.to_string(), self.checkpoints.len() - 1); - // Cache file stats for this capture + // Cache file stats for this checkpoint if let Ok(stats) = self.compute_file_stats(tag) { self.file_stats_cache.insert(tag.to_string(), stats); } @@ -157,9 +157,9 @@ impl CaptureManager { Ok(()) } - /// Restore workspace to a specific capture + /// Restore workspace to a specific checkpoint pub fn restore(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { - let capture = self.get_capture(tag)?; + let checkpoint = self.get_checkpoint(tag)?; // Restore files let args = if hard { @@ -174,7 +174,7 @@ impl CaptureManager { } // Restore conversation history - while conversation.history().len() > capture.history_index { + while conversation.history().len() > checkpoint.history_index { conversation .pop_from_history() .ok_or(eyre!("Failed to restore conversation history"))?; @@ -183,7 +183,7 @@ impl CaptureManager { Ok(()) } - /// Get file change statistics for a capture + /// Get file change statistics for a checkpoint pub fn compute_file_stats(&self, tag: &str) -> Result { if tag == "0" { return Ok(FileStats::default()); @@ -193,7 +193,7 @@ impl CaptureManager { self.compute_stats_between(&prev_tag, tag) } - /// Compute file statistics between two captures + /// Compute file statistics between two checkpoints pub fn compute_stats_between(&self, from: &str, to: &str) -> Result { let output = run_git(&self.shadow_repo_path, false, &["diff", "--name-status", from, to])?; @@ -213,7 +213,7 @@ impl CaptureManager { Ok(stats) } - /// Generate detailed diff between captures + /// Generate detailed diff between checkpoints pub fn diff(&self, from: &str, to: &str) -> Result { let mut result = String::new(); @@ -263,15 +263,15 @@ impl CaptureManager { Ok(()) } - fn get_capture(&self, tag: &str) -> Result<&Capture> { + fn get_checkpoint(&self, tag: &str) -> Result<&Checkpoint> { self.tag_index .get(tag) - .and_then(|&idx| self.captures.get(idx)) - .ok_or_else(|| eyre!("Capture '{}' not found", tag)) + .and_then(|&idx| self.checkpoints.get(idx)) + .ok_or_else(|| eyre!("Checkpoint '{}' not found", tag)) } } -impl Drop for CaptureManager { +impl Drop for CheckpointManager { fn drop(&mut self) { let path = self.shadow_repo_path.clone(); // Try to spawn cleanup task @@ -304,7 +304,7 @@ pub fn truncate_message(s: &str, max_len: usize) -> String { } } -pub const CAPTURE_MESSAGE_MAX_LENGTH: usize = 60; +pub const CHECKPOINT_MESSAGE_MAX_LENGTH: usize = 60; fn is_git_installed() -> bool { Command::new("git") diff --git a/crates/chat-cli/src/cli/chat/cli/capture.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs similarity index 68% rename from crates/chat-cli/src/cli/chat/cli/capture.rs rename to crates/chat-cli/src/cli/chat/cli/checkpoint.rs index eaa1cd11a9..1697dc0831 100644 --- a/crates/chat-cli/src/cli/chat/cli/capture.rs +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -13,9 +13,9 @@ use crossterm::{ }; use dialoguer::Select; -use crate::cli::chat::capture::{ - Capture, - CaptureManager, +use crate::cli::chat::checkpoint::{ + Checkpoint, + CheckpointManager, FileStats, }; use crate::cli::chat::{ @@ -28,33 +28,33 @@ use crate::os::Os; use crate::util::directories::get_shadow_repo_dir; #[derive(Debug, PartialEq, Subcommand)] -pub enum CaptureSubcommand { - /// Initialize captures manually +pub enum CheckpointSubcommand { + /// Initialize checkpoints manually Init, - /// Restore workspace to a capture + /// Restore workspace to a checkpoint #[command( - about = "Restore workspace to a capture", - long_about = r#"Restore files to a capture . If is omitted, you'll pick one interactively. + about = "Restore workspace to a checkpoint", + long_about = r#"Restore files to a checkpoint . If is omitted, you'll pick one interactively. Default mode: • Restores tracked file changes - • Keeps new files created after the capture + • Keeps new files created after the checkpoint With --hard: - • Exactly matches the capture state - • Removes files created after the capture"# + • Exactly matches the checkpoint state + • Removes files created after the checkpoint"# )] Restore { - /// Capture tag (e.g., 3 or 3.1). Leave empty to select interactively. + /// Checkpoint tag (e.g., 3 or 3.1). Leave empty to select interactively. tag: Option, - /// Exactly match capture state (removes newer files) + /// Exactly match checkpoint state (removes newer files) #[arg(long)] hard: bool, }, - /// List all captures + /// List all checkpoints List { /// Limit number of results shown #[arg(short, long)] @@ -64,31 +64,36 @@ With --hard: /// Delete the shadow repository Clean, - /// Show details of a capture + /// Show details of a checkpoint Expand { - /// Capture tag to expand + /// Checkpoint tag to expand tag: String, }, - /// Show differences between captures + /// Show differences between checkpoints Diff { - /// First capture tag + /// First checkpoint tag tag1: String, - /// Second capture tag (defaults to current state) + /// Second checkpoint tag (defaults to current state) #[arg(required = false)] tag2: Option, }, } -impl CaptureSubcommand { +impl CheckpointSubcommand { pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { - // Check if capture is enabled - if !os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + // Check if checkpoint is enabled + if !os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { execute!( session.stderr, style::SetForegroundColor(Color::Red), - style::Print("\nCapture is disabled. Enable it with: q settings chat.enableCapture true\n"), + style::Print("\nCheckpoint is disabled. Enable it with: q settings chat.enableCheckpoint true\n"), style::SetForegroundColor(Color::Reset) )?; return Ok(ChatState::PromptUser { @@ -106,12 +111,12 @@ impl CaptureSubcommand { } async fn handle_init(&self, os: &Os, session: &mut ChatSession) -> Result { - if session.conversation.capture_manager.is_some() { + if session.conversation.checkpoint_manager.is_some() { execute!( session.stderr, style::SetForegroundColor(Color::Blue), style::Print( - "✓ Captures are already enabled for this session! Use /capture list to see current captures.\n" + "✓ Checkpoints are already enabled for this session! Use /checkpoint list to see current checkpoints.\n" ), style::SetForegroundColor(Color::Reset) )?; @@ -120,10 +125,10 @@ impl CaptureSubcommand { .map_err(|e| ChatError::Custom(e.to_string().into()))?; let start = std::time::Instant::now(); - session.conversation.capture_manager = Some( - CaptureManager::manual_init(os, path) + session.conversation.checkpoint_manager = Some( + CheckpointManager::manual_init(os, path) .await - .map_err(|e| ChatError::Custom(format!("Captures could not be initialized: {e}").into()))?, + .map_err(|e| ChatError::Custom(format!("Checkpoints could not be initialized: {e}").into()))?, ); execute!( @@ -131,7 +136,7 @@ impl CaptureSubcommand { style::SetForegroundColor(Color::Blue), style::SetAttribute(Attribute::Bold), style::Print(format!( - "✓ Captures are enabled! (took {:.2}s)\n", + "📷 Checkpoints are enabled! (took {:.2}s)\n", start.elapsed().as_secs_f32() )), style::SetForegroundColor(Color::Reset), @@ -151,11 +156,11 @@ impl CaptureSubcommand { hard: bool, ) -> Result { // Take manager out temporarily to avoid borrow issues - let Some(manager) = session.conversation.capture_manager.take() else { + let Some(manager) = session.conversation.checkpoint_manager.take() else { execute!( session.stderr, style::SetForegroundColor(Color::Yellow), - style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { @@ -167,17 +172,17 @@ impl CaptureSubcommand { Ok(tag) } else { // Interactive selection - match gather_turn_captures(&manager) { + match gather_turn_checkpoints(&manager) { Ok(entries) => { - if let Some(idx) = select_capture(&entries, "Select capture to restore:") { + if let Some(idx) = select_checkpoint(&entries, "Select checkpoint to restore:") { Ok(entries[idx].tag.clone()) } else { Err(()) } }, Err(e) => { - session.conversation.capture_manager = Some(manager); - return Err(ChatError::Custom(format!("Failed to gather captures: {}", e).into())); + session.conversation.checkpoint_manager = Some(manager); + return Err(ChatError::Custom(format!("Failed to gather checkpoints: {}", e).into())); }, } }; @@ -185,7 +190,7 @@ impl CaptureSubcommand { let tag = match tag_result { Ok(tag) => tag, Err(_) => { - session.conversation.capture_manager = Some(manager); + session.conversation.checkpoint_manager = Some(manager); return Ok(ChatState::PromptUser { skip_printing_tools: true, }); @@ -198,14 +203,14 @@ impl CaptureSubcommand { session.stderr, style::SetForegroundColor(Color::Blue), style::SetAttribute(Attribute::Bold), - style::Print(format!("✓ Restored to capture {}\n", tag)), + style::Print(format!("✓ Restored to checkpoint {}\n", tag)), style::SetForegroundColor(Color::Reset), style::SetAttribute(Attribute::Reset), )?; - session.conversation.capture_manager = Some(manager); + session.conversation.checkpoint_manager = Some(manager); }, Err(e) => { - session.conversation.capture_manager = Some(manager); + session.conversation.checkpoint_manager = Some(manager); return Err(ChatError::Custom(format!("Failed to restore: {}", e).into())); }, } @@ -216,11 +221,11 @@ impl CaptureSubcommand { } fn handle_list(session: &mut ChatSession, limit: Option) -> Result { - let Some(manager) = session.conversation.capture_manager.as_ref() else { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { execute!( session.stderr, style::SetForegroundColor(Color::Yellow), - style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { @@ -228,8 +233,8 @@ impl CaptureSubcommand { }); }; - print_captures(manager, &mut session.stderr, limit) - .map_err(|e| ChatError::Custom(format!("Could not display all captures: {}", e).into()))?; + print_checkpoints(manager, &mut session.stderr, limit) + .map_err(|e| ChatError::Custom(format!("Could not display all checkpoints: {}", e).into()))?; Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -237,11 +242,11 @@ impl CaptureSubcommand { } async fn handle_clean(&self, os: &Os, session: &mut ChatSession) -> Result { - let Some(manager) = session.conversation.capture_manager.take() else { + let Some(manager) = session.conversation.checkpoint_manager.take() else { execute!( session.stderr, style::SetForegroundColor(Color::Yellow), - style::Print("⚠️ ️Captures not enabled.\n"), + style::Print("⚠️ ️Checkpoints not enabled.\n"), style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { @@ -265,7 +270,7 @@ impl CaptureSubcommand { )?; }, Err(e) => { - session.conversation.capture_manager = Some(manager); + session.conversation.checkpoint_manager = Some(manager); return Err(ChatError::Custom(format!("Failed to clean: {e}").into())); }, } @@ -276,11 +281,11 @@ impl CaptureSubcommand { } fn handle_expand(session: &mut ChatSession, tag: String) -> Result { - let Some(manager) = session.conversation.capture_manager.as_ref() else { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { execute!( session.stderr, style::SetForegroundColor(Color::Yellow), - style::Print("⚠️ ️Captures not enabled. Use '/capture init' to enable.\n"), + style::Print("⚠️ ️Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { @@ -288,8 +293,8 @@ impl CaptureSubcommand { }); }; - expand_capture(manager, &mut session.stderr, &tag) - .map_err(|e| ChatError::Custom(format!("Failed to expand capture: {}", e).into()))?; + expand_checkpoint(manager, &mut session.stderr, &tag) + .map_err(|e| ChatError::Custom(format!("Failed to expand checkpoint: {}", e).into()))?; Ok(ChatState::PromptUser { skip_printing_tools: true, @@ -297,11 +302,11 @@ impl CaptureSubcommand { } fn handle_diff(session: &mut ChatSession, tag1: String, tag2: Option) -> Result { - let Some(manager) = session.conversation.capture_manager.as_ref() else { + let Some(manager) = session.conversation.checkpoint_manager.as_ref() else { execute!( session.stderr, style::SetForegroundColor(Color::Yellow), - style::Print("⚠️ Captures not enabled. Use '/capture init' to enable.\n"), + style::Print("⚠️ Checkpoints not enabled. Use '/checkpoint init' to enable.\n"), style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { @@ -317,7 +322,7 @@ impl CaptureSubcommand { session.stderr, style::SetForegroundColor(Color::Yellow), style::Print(format!( - "⚠️ Capture '{}' not found! Use /capture list to see available captures\n", + "⚠️ Checkpoint '{}' not found! Use /checkpoint list to see available checkpoints\n", tag1 )), style::SetForegroundColor(Color::Reset), @@ -332,7 +337,7 @@ impl CaptureSubcommand { session.stderr, style::SetForegroundColor(Color::Yellow), style::Print(format!( - "⚠️ Capture '{}' not found! Use /capture list to see available captures\n", + "⚠️ Checkpoint '{}' not found! Use /checkpoint list to see available checkpoints\n", tag2 )), style::SetForegroundColor(Color::Reset), @@ -343,7 +348,7 @@ impl CaptureSubcommand { } let header = if tag2 == "HEAD" { - format!("Changes since capture {}:\n", tag1) + format!("Changes since checkpoint {}:\n", tag1) } else { format!("Changes from {} to {}:\n", tag1, tag2) }; @@ -381,52 +386,52 @@ impl CaptureSubcommand { // Display helpers -struct CaptureDisplay { +struct CheckpointDisplay { tag: String, parts: Vec>, } -impl CaptureDisplay { - fn from_capture(capture: &Capture, manager: &CaptureManager) -> Result { +impl CheckpointDisplay { + fn from_checkpoint(checkpoint: &Checkpoint, manager: &CheckpointManager) -> Result { let mut parts = Vec::new(); // Tag - parts.push(format!("[{}] ", capture.tag).blue()); + parts.push(format!("[{}] ", checkpoint.tag).blue()); // Content - if capture.is_turn { - // Turn capture: show timestamp and description + if checkpoint.is_turn { + // Turn checkpoint: show timestamp and description parts.push( format!( "{} - {}", - capture.timestamp.format("%Y-%m-%d %H:%M:%S"), - capture.description + checkpoint.timestamp.format("%Y-%m-%d %H:%M:%S"), + checkpoint.description ) .reset(), ); // Add file stats if available - if let Some(stats) = manager.file_stats_cache.get(&capture.tag) { + if let Some(stats) = manager.file_stats_cache.get(&checkpoint.tag) { let stats_str = format_stats(stats); if !stats_str.is_empty() { parts.push(format!(" ({})", stats_str).dark_grey()); } } } else { - // Tool capture: show tool name and description - let tool_name = capture.tool_name.clone().unwrap_or_else(|| "Tool".to_string()); + // Tool checkpoint: show tool name and description + let tool_name = checkpoint.tool_name.clone().unwrap_or_else(|| "Tool".to_string()); parts.push(format!("{}: ", tool_name).magenta()); - parts.push(capture.description.clone().reset()); + parts.push(checkpoint.description.clone().reset()); } Ok(Self { - tag: capture.tag.clone(), + tag: checkpoint.tag.clone(), parts, }) } } -impl std::fmt::Display for CaptureDisplay { +impl std::fmt::Display for CheckpointDisplay { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { for part in &self.parts { write!(f, "{}", part)?; @@ -451,17 +456,21 @@ fn format_stats(stats: &FileStats) -> String { parts.join(" ") } -fn gather_turn_captures(manager: &CaptureManager) -> Result, eyre::Report> { +fn gather_turn_checkpoints(manager: &CheckpointManager) -> Result, eyre::Report> { manager - .captures + .checkpoints .iter() .filter(|c| c.is_turn) - .map(|c| CaptureDisplay::from_capture(c, manager)) + .map(|c| CheckpointDisplay::from_checkpoint(c, manager)) .collect() } -fn print_captures(manager: &CaptureManager, output: &mut impl Write, limit: Option) -> Result<(), eyre::Report> { - let entries = gather_turn_captures(manager)?; +fn print_checkpoints( + manager: &CheckpointManager, + output: &mut impl Write, + limit: Option, +) -> Result<(), eyre::Report> { + let entries = gather_turn_checkpoints(manager)?; let limit = limit.unwrap_or(entries.len()); for entry in entries.iter().take(limit) { @@ -471,42 +480,42 @@ fn print_captures(manager: &CaptureManager, output: &mut impl Write, limit: Opti Ok(()) } -fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) -> Result<(), eyre::Report> { +fn expand_checkpoint(manager: &CheckpointManager, output: &mut impl Write, tag: &str) -> Result<(), eyre::Report> { let Some(&idx) = manager.tag_index.get(tag) else { execute!( output, style::SetForegroundColor(Color::Yellow), - style::Print(format!("⚠️ capture '{}' not found\n", tag)), + style::Print(format!("⚠️ checkpoint '{}' not found\n", tag)), style::SetForegroundColor(Color::Reset), )?; return Ok(()); }; - let capture = &manager.captures[idx]; + let checkpoint = &manager.checkpoints[idx]; - // Print main capture - let display = CaptureDisplay::from_capture(capture, manager)?; + // Print main checkpoint + let display = CheckpointDisplay::from_checkpoint(checkpoint, manager)?; execute!(output, style::Print(&display), style::Print("\n"))?; - if !capture.is_turn { + if !checkpoint.is_turn { return Ok(()); } - // Print tool captures for this turn - let mut tool_captures = Vec::new(); + // Print tool checkpoints for this turn + let mut tool_checkpoints = Vec::new(); for i in (0..idx).rev() { - let c = &manager.captures[i]; + let c = &manager.checkpoints[i]; if c.is_turn { break; } - tool_captures.push((i, CaptureDisplay::from_capture(c, manager)?)); + tool_checkpoints.push((i, CheckpointDisplay::from_checkpoint(c, manager)?)); } - for (capture_idx, display) in tool_captures.iter().rev() { + for (checkpoint_idx, display) in tool_checkpoints.iter().rev() { // Compute stats for this tool - let curr_tag = &manager.captures[*capture_idx].tag; - let prev_tag = if *capture_idx > 0 { - &manager.captures[capture_idx - 1].tag + let curr_tag = &manager.checkpoints[*checkpoint_idx].tag; + let prev_tag = if *checkpoint_idx > 0 { + &manager.checkpoints[checkpoint_idx - 1].tag } else { "0" }; @@ -539,7 +548,7 @@ fn expand_capture(manager: &CaptureManager, output: &mut impl Write, tag: &str) Ok(()) } -fn select_capture(entries: &[CaptureDisplay], prompt: &str) -> Option { +fn select_checkpoint(entries: &[CheckpointDisplay], prompt: &str) -> Option { Select::with_theme(&crate::util::dialoguer_theme()) .with_prompt(prompt) .items(entries) diff --git a/crates/chat-cli/src/cli/chat/cli/experiment.rs b/crates/chat-cli/src/cli/chat/cli/experiment.rs index 0f6784d930..0e1a1cb466 100644 --- a/crates/chat-cli/src/cli/chat/cli/experiment.rs +++ b/crates/chat-cli/src/cli/chat/cli/experiment.rs @@ -51,9 +51,9 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[ setting_key: Setting::EnabledTodoList, }, Experiment { - name: "Capture", - description: "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/capture)", - setting_key: Setting::EnabledCapture, + name: "Checkpoint", + description: "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)", + setting_key: Setting::EnabledCheckpoint, }, ]; diff --git a/crates/chat-cli/src/cli/chat/cli/mod.rs b/crates/chat-cli/src/cli/chat/cli/mod.rs index 652c11343b..2715f66d0e 100644 --- a/crates/chat-cli/src/cli/chat/cli/mod.rs +++ b/crates/chat-cli/src/cli/chat/cli/mod.rs @@ -1,5 +1,5 @@ -pub mod capture; pub mod changelog; +pub mod checkpoint; pub mod clear; pub mod compact; pub mod context; @@ -36,7 +36,7 @@ use tangent::TangentArgs; use todos::TodoSubcommand; use tools::ToolsArgs; -use crate::cli::chat::cli::capture::CaptureSubcommand; +use crate::cli::chat::cli::checkpoint::CheckpointSubcommand; use crate::cli::chat::cli::subscribe::SubscribeArgs; use crate::cli::chat::cli::usage::UsageArgs; use crate::cli::chat::consts::AGENT_MIGRATION_DOC_URL; @@ -105,7 +105,7 @@ pub enum SlashCommand { // #[command(flatten)] // Root(RootSubcommand), #[command(subcommand)] - Capture(CaptureSubcommand), + Checkpoint(CheckpointSubcommand), /// View, manage, and resume to-do lists #[command(subcommand)] Todos(TodoSubcommand), @@ -173,7 +173,7 @@ impl SlashCommand { // skip_printing_tools: true, // }) // }, - Self::Capture(subcommand) => subcommand.execute(os, session).await, + Self::Checkpoint(subcommand) => subcommand.execute(os, session).await, Self::Todos(subcommand) => subcommand.execute(os, session).await, } } @@ -203,7 +203,7 @@ impl SlashCommand { PersistSubcommand::Save { .. } => "save", PersistSubcommand::Load { .. } => "load", }, - Self::Capture(_) => "capture", + Self::Checkpoint(_) => "checkpoint", Self::Todos(_) => "todos", } } diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index 601e6213e0..424685b68f 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -73,7 +73,7 @@ use crate::cli::agent::hook::{ HookTrigger, }; use crate::cli::chat::ChatError; -use crate::cli::chat::capture::CaptureManager; +use crate::cli::chat::checkpoint::CheckpointManager; use crate::cli::chat::cli::model::{ ModelInfo, get_model_info, @@ -139,7 +139,7 @@ pub struct ConversationState { #[serde(default)] pub file_line_tracker: HashMap, - pub capture_manager: Option, + pub checkpoint_manager: Option, #[serde(default = "default_true")] pub mcp_enabled: bool, /// Tangent mode checkpoint - stores main conversation when in tangent mode @@ -205,7 +205,7 @@ impl ConversationState { model: None, model_info: model, file_line_tracker: HashMap::new(), - capture_manager: None, + checkpoint_manager: None, mcp_enabled, tangent_state: None, } @@ -276,7 +276,7 @@ impl ConversationState { /// Exit tangent mode and preserve the last conversation entry (user + assistant) pub fn exit_tangent_mode_with_tail(&mut self) { if let Some(checkpoint) = self.tangent_state.take() { - // Capture the last history entry from tangent conversation if it exists + // Checkpoint the last history entry from tangent conversation if it exists // and if it's different from what was in the main conversation let last_entry = if self.history.len() > checkpoint.main_history.len() { self.history.back().cloned() diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 10d51e57e8..ec09b83ff2 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -6,13 +6,13 @@ mod input_source; mod message; mod parse; use std::path::MAIN_SEPARATOR; -pub mod capture; +pub mod checkpoint; mod line_tracker; mod parser; mod prompt; mod prompt_parser; pub mod server_messenger; -use crate::cli::chat::capture::CAPTURE_MESSAGE_MAX_LENGTH; +use crate::cli::chat::checkpoint::CHECKPOINT_MESSAGE_MAX_LENGTH; #[cfg(unix)] mod skim_integration; mod token_counter; @@ -144,8 +144,8 @@ use crate::auth::AuthError; use crate::auth::builder_id::is_idc_user; use crate::cli::TodoListState; use crate::cli::agent::Agents; -use crate::cli::chat::capture::{ - CaptureManager, +use crate::cli::chat::checkpoint::{ + CheckpointManager, truncate_message, }; use crate::cli::chat::cli::SlashCommand; @@ -1330,17 +1330,25 @@ impl ChatSession { } // Initialize capturing if possible - if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; let start = std::time::Instant::now(); - let capture_manager = match CaptureManager::auto_init(os, &path).await { + let checkpoint_manager = match CheckpointManager::auto_init(os, &path).await { Ok(manager) => { execute!( self.stderr, style::Print( - format!("Captures are enabled! (took {:.2}s)\n\n", start.elapsed().as_secs_f32()) - .blue() - .bold() + format!( + "📷 Checkpoints are enabled! (took {:.2}s)\n\n", + start.elapsed().as_secs_f32() + ) + .blue() + .bold() ) )?; Some(manager) @@ -1350,7 +1358,7 @@ impl ChatSession { None }, }; - self.conversation.capture_manager = capture_manager; + self.conversation.checkpoint_manager = checkpoint_manager; } if let Some(user_input) = self.initial_input.take() { @@ -2114,10 +2122,15 @@ impl ChatSession { skip_printing_tools: false, }) } else { - // Track the message for capture descriptions, but only if not already set + // Track the message for checkpoint descriptions, but only if not already set // This prevents tool approval responses (y/n/t) from overwriting the original message - if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { - if let Some(manager) = self.conversation.capture_manager.as_mut() { + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + if let Some(manager) = self.conversation.checkpoint_manager.as_mut() { if !manager.message_locked && self.pending_tool_index.is_none() { manager.pending_user_message = Some(user_input.clone()); manager.message_locked = true; @@ -2347,14 +2360,18 @@ impl ChatSession { } execute!(self.stdout, style::Print("\n"))?; - // Handle capture after tool execution - store tag for later display - let capture_tag = { - let enabled = os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false); + // Handle checkpoint after tool execution - store tag for later display + let checkpoint_tag = { + let enabled = os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false); if invoke_result.is_err() || !enabled { String::new() } // Take manager out temporarily to avoid borrow conflicts - else if let Some(mut manager) = self.conversation.capture_manager.take() { + else if let Some(mut manager) = self.conversation.checkpoint_manager.take() { // Check if there are uncommitted changes let has_changes = match manager.has_changes() { Ok(b) => b, @@ -2386,11 +2403,15 @@ impl ChatSession { // Get history length before putting manager back let history_len = self.conversation.history().len(); - // Create capture - if let Err(e) = - manager.create_capture(&tag, &description, history_len + 1, false, Some(tool.name.clone())) - { - debug!("Failed to create tool capture: {}", e); + // Create checkpoint + if let Err(e) = manager.create_checkpoint( + &tag, + &description, + history_len + 1, + false, + Some(tool.name.clone()), + ) { + debug!("Failed to create tool checkpoint: {}", e); String::new() } else { manager.tools_in_turn += 1; @@ -2401,7 +2422,7 @@ impl ChatSession { }; // Put manager back - self.conversation.capture_manager = Some(manager); + self.conversation.checkpoint_manager = Some(manager); tag } else { String::new() @@ -2451,8 +2472,8 @@ impl ChatSession { style::Print(format!(" ● Completed in {}s", tool_time)), style::SetForegroundColor(Color::Reset), )?; - if !capture_tag.is_empty() { - execute!(self.stdout, style::Print(format!(" [{capture_tag}]").blue().bold()))?; + if !checkpoint_tag.is_empty() { + execute!(self.stdout, style::Print(format!(" [{checkpoint_tag}]").blue().bold()))?; } execute!(self.stdout, style::Print("\n\n"))?; @@ -2859,9 +2880,14 @@ impl ChatSession { self.pending_tool_index = None; self.tool_turn_start_time = None; - // Create turn capture if tools were used - if os.database.settings.get_bool(Setting::EnabledCapture).unwrap_or(false) { - if let Some(mut manager) = self.conversation.capture_manager.take() { + // Create turn checkpoint if tools were used + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + if let Some(mut manager) = self.conversation.checkpoint_manager.take() { if manager.tools_in_turn > 0 { // Increment turn counter manager.current_turn += 1; @@ -2869,19 +2895,19 @@ impl ChatSession { // Get user message for description let description = manager.pending_user_message.take().map_or_else( || "Turn completed".to_string(), - |msg| truncate_message(&msg, CAPTURE_MESSAGE_MAX_LENGTH), + |msg| truncate_message(&msg, CHECKPOINT_MESSAGE_MAX_LENGTH), ); // Get history length before putting manager back let history_len = self.conversation.history().len(); - // Create turn capture + // Create turn checkpoint let tag = manager.current_turn.to_string(); - if let Err(e) = manager.create_capture(&tag, &description, history_len, true, None) { + if let Err(e) = manager.create_checkpoint(&tag, &description, history_len, true, None) { execute!( self.stderr, style::SetForegroundColor(Color::Yellow), - style::Print(format!("⚠️ Could not create automatic capture: {}\n\n", e)), + style::Print(format!("⚠️ Could not create automatic checkpoint: {}\n\n", e)), style::SetForegroundColor(Color::Reset), )?; } else { @@ -2889,7 +2915,7 @@ impl ChatSession { self.stderr, style::SetForegroundColor(Color::Blue), style::SetAttribute(Attribute::Bold), - style::Print(format!("✓ Created capture {}\n\n", tag)), + style::Print(format!("✓ Created checkpoint {}\n\n", tag)), style::SetForegroundColor(Color::Reset), style::SetAttribute(Attribute::Reset), )?; @@ -2904,7 +2930,7 @@ impl ChatSession { } // Put manager back - self.conversation.capture_manager = Some(manager); + self.conversation.checkpoint_manager = Some(manager); } } diff --git a/crates/chat-cli/src/database/settings.rs b/crates/chat-cli/src/database/settings.rs index 69c293efdc..dcbffc8f41 100644 --- a/crates/chat-cli/src/database/settings.rs +++ b/crates/chat-cli/src/database/settings.rs @@ -77,8 +77,8 @@ pub enum Setting { ChatEnableHistoryHints, #[strum(message = "Enable the todo list feature (boolean)")] EnabledTodoList, - #[strum(message = "Enable the capture feature (boolean)")] - EnabledCapture, + #[strum(message = "Enable the checkpoint feature (boolean)")] + EnabledCheckpoint, } impl AsRef for Setting { @@ -114,7 +114,7 @@ impl AsRef for Setting { Self::ChatDisableAutoCompaction => "chat.disableAutoCompaction", Self::ChatEnableHistoryHints => "chat.enableHistoryHints", Self::EnabledTodoList => "chat.enableTodoList", - Self::EnabledCapture => "chat.enableCapture", + Self::EnabledCheckpoint => "chat.enableCheckpoint", } } } @@ -160,7 +160,7 @@ impl TryFrom<&str> for Setting { "chat.disableAutoCompaction" => Ok(Self::ChatDisableAutoCompaction), "chat.enableHistoryHints" => Ok(Self::ChatEnableHistoryHints), "chat.enableTodoList" => Ok(Self::EnabledTodoList), - "chat.enableCapture" => Ok(Self::EnabledCapture), + "chat.enableCheckpoint" => Ok(Self::EnabledCheckpoint), _ => Err(DatabaseError::InvalidSetting(value.to_string())), } } @@ -297,7 +297,7 @@ mod test { .set(Setting::ChatDisableMarkdownRendering, false) .await .unwrap(); - settings.set(Setting::EnabledCapture, true).await.unwrap(); + settings.set(Setting::EnabledCheckpoint, true).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), Some(&Value::Bool(true))); assert_eq!( @@ -321,7 +321,7 @@ mod test { settings.get(Setting::ChatDisableMarkdownRendering), Some(&Value::Bool(false)) ); - assert_eq!(settings.get(Setting::EnabledCapture), Some(&Value::Bool(true))); + assert_eq!(settings.get(Setting::EnabledCheckpoint), Some(&Value::Bool(true))); settings.remove(Setting::TelemetryEnabled).await.unwrap(); settings.remove(Setting::OldClientId).await.unwrap(); @@ -329,7 +329,7 @@ mod test { settings.remove(Setting::KnowledgeIndexType).await.unwrap(); settings.remove(Setting::McpLoadedBefore).await.unwrap(); settings.remove(Setting::ChatDisableMarkdownRendering).await.unwrap(); - settings.remove(Setting::EnabledCapture).await.unwrap(); + settings.remove(Setting::EnabledCheckpoint).await.unwrap(); assert_eq!(settings.get(Setting::TelemetryEnabled), None); assert_eq!(settings.get(Setting::OldClientId), None); @@ -337,6 +337,6 @@ mod test { assert_eq!(settings.get(Setting::KnowledgeIndexType), None); assert_eq!(settings.get(Setting::McpLoadedBefore), None); assert_eq!(settings.get(Setting::ChatDisableMarkdownRendering), None); - assert_eq!(settings.get(Setting::EnabledCapture), None); + assert_eq!(settings.get(Setting::EnabledCheckpoint), None); } } diff --git a/crates/chat-cli/src/util/directories.rs b/crates/chat-cli/src/util/directories.rs index 0429a0698f..8a4ac1bfb5 100644 --- a/crates/chat-cli/src/util/directories.rs +++ b/crates/chat-cli/src/util/directories.rs @@ -43,7 +43,7 @@ pub enum DirectoryError { type Result = std::result::Result; const WORKSPACE_AGENT_DIR_RELATIVE: &str = ".amazonq/cli-agents"; -const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/amazonq/cli-captures"; +const GLOBAL_SHADOW_REPO_DIR: &str = ".aws/amazonq/cli-checkpoints"; const GLOBAL_AGENT_DIR_RELATIVE_TO_HOME: &str = ".aws/amazonq/cli-agents"; const CLI_BASH_HISTORY_PATH: &str = ".aws/amazonq/.cli_bash_history"; From e9f30d92a2d78eea000fc0d948f5055022300ee3 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Mon, 22 Sep 2025 16:35:57 -0700 Subject: [PATCH 25/31] reverse false renaming --- crates/chat-cli/src/cli/chat/conversation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index 424685b68f..ceedf67514 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -276,7 +276,7 @@ impl ConversationState { /// Exit tangent mode and preserve the last conversation entry (user + assistant) pub fn exit_tangent_mode_with_tail(&mut self) { if let Some(checkpoint) = self.tangent_state.take() { - // Checkpoint the last history entry from tangent conversation if it exists + // Capture the last history entry from tangent conversation if it exists // and if it's different from what was in the main conversation let last_entry = if self.history.len() > checkpoint.main_history.len() { self.history.back().cloned() From 9211e75f3c80419818d1d10611bbda245db017c9 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 24 Sep 2025 13:43:50 -0700 Subject: [PATCH 26/31] recover history --- crates/chat-cli/src/cli/chat/checkpoint.rs | 72 +++++++++++++------ .../chat-cli/src/cli/chat/cli/checkpoint.rs | 2 +- crates/chat-cli/src/cli/chat/conversation.rs | 20 ++++-- crates/chat-cli/src/cli/chat/mod.rs | 17 ++--- 4 files changed, 76 insertions(+), 35 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/checkpoint.rs b/crates/chat-cli/src/cli/chat/checkpoint.rs index 363da0548d..c5fb0b8183 100644 --- a/crates/chat-cli/src/cli/chat/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/checkpoint.rs @@ -1,4 +1,7 @@ -use std::collections::HashMap; +use std::collections::{ + HashMap, + VecDeque, +}; use std::path::{ Path, PathBuf, @@ -24,6 +27,7 @@ use serde::{ }; use crate::cli::ConversationState; +use crate::cli::chat::conversation::HistoryEntry; use crate::os::Os; /// Manages a shadow git repository for tracking and restoring workspace changes @@ -67,14 +71,18 @@ pub struct Checkpoint { pub tag: String, pub timestamp: DateTime, pub description: String, - pub history_index: usize, + pub history_snapshot: VecDeque, pub is_turn: bool, pub tool_name: Option, } impl CheckpointManager { /// Initialize checkpoint manager automatically (when in a git repo) - pub async fn auto_init(os: &Os, shadow_path: impl AsRef) -> Result { + pub async fn auto_init( + os: &Os, + shadow_path: impl AsRef, + current_history: &VecDeque, + ) -> Result { if !is_git_installed() { bail!("Git is not installed. Checkpoints require git to function."); } @@ -82,12 +90,16 @@ impl CheckpointManager { bail!("Not in a git repository. Use '/checkpoint init' to manually enable checkpoints."); } - let manager = Self::manual_init(os, shadow_path).await?; + let manager = Self::manual_init(os, shadow_path, current_history).await?; Ok(manager) } /// Initialize checkpoint manager manually - pub async fn manual_init(os: &Os, path: impl AsRef) -> Result { + pub async fn manual_init( + os: &Os, + path: impl AsRef, + current_history: &VecDeque, + ) -> Result { let path = path.as_ref(); os.fs.create_dir_all(path).await?; @@ -104,7 +116,7 @@ impl CheckpointManager { tag: "0".to_string(), timestamp: Local::now(), description: "Initial state".to_string(), - history_index: 0, + history_snapshot: current_history.clone(), is_turn: true, tool_name: None, }; @@ -129,7 +141,7 @@ impl CheckpointManager { &mut self, tag: &str, description: &str, - history_index: usize, + history: &VecDeque, is_turn: bool, tool_name: Option, ) -> Result<()> { @@ -141,7 +153,7 @@ impl CheckpointManager { tag: tag.to_string(), timestamp: Local::now(), description: description.to_string(), - history_index, + history_snapshot: history.clone(), is_turn, tool_name, }; @@ -161,28 +173,44 @@ impl CheckpointManager { pub fn restore(&self, conversation: &mut ConversationState, tag: &str, hard: bool) -> Result<()> { let checkpoint = self.get_checkpoint(tag)?; - // Restore files - let args = if hard { - vec!["reset", "--hard", tag] + if hard { + // Hard: reset the whole work-tree to the tag + let output = run_git(&self.shadow_repo_path, true, &["reset", "--hard", tag])?; + if !output.status.success() { + bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); + } } else { - vec!["checkout", tag, "--", "."] - }; - - let output = run_git(&self.shadow_repo_path, true, &args)?; - if !output.status.success() { - bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); + // Soft: only restore tracked files. If the tag is an empty tree, this is a no-op. + if !self.tag_has_any_paths(tag)? { + // Nothing tracked in this checkpoint -> nothing to restore; treat as success. + conversation.restore_to_checkpoint(checkpoint)?; + return Ok(()); + } + // Use checkout against work-tree + let output = run_git(&self.shadow_repo_path, true, &["checkout", tag, "--", "."])?; + if !output.status.success() { + bail!("Failed to restore: {}", String::from_utf8_lossy(&output.stderr)); + } } // Restore conversation history - while conversation.history().len() > checkpoint.history_index { - conversation - .pop_from_history() - .ok_or(eyre!("Failed to restore conversation history"))?; - } + conversation.restore_to_checkpoint(checkpoint)?; Ok(()) } + /// Return true iff the given tag/tree has any tracked paths. + fn tag_has_any_paths(&self, tag: &str) -> eyre::Result { + // Use `git ls-tree -r --name-only ` to check if the tree is empty + let out = run_git( + &self.shadow_repo_path, + // work_tree + false, + &["ls-tree", "-r", "--name-only", tag], + )?; + Ok(!out.stdout.is_empty()) + } + /// Get file change statistics for a checkpoint pub fn compute_file_stats(&self, tag: &str) -> Result { if tag == "0" { diff --git a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs index 1697dc0831..2dd97135ec 100644 --- a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -126,7 +126,7 @@ impl CheckpointSubcommand { let start = std::time::Instant::now(); session.conversation.checkpoint_manager = Some( - CheckpointManager::manual_init(os, path) + CheckpointManager::manual_init(os, path, session.conversation.history()) .await .map_err(|e| ChatError::Custom(format!("Checkpoints could not be initialized: {e}").into()))?, ); diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index ceedf67514..979475078b 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -73,7 +73,10 @@ use crate::cli::agent::hook::{ HookTrigger, }; use crate::cli::chat::ChatError; -use crate::cli::chat::checkpoint::CheckpointManager; +use crate::cli::chat::checkpoint::{ + Checkpoint, + CheckpointManager, +}; use crate::cli::chat::cli::model::{ ModelInfo, get_model_info, @@ -880,9 +883,18 @@ Return only the JSON configuration, no additional text.", self.transcript.push_back(message); } - pub fn pop_from_history(&mut self) -> Option<()> { - self.history.pop_back()?; - Some(()) + /// Restore conversation from a capture's history snapshot + pub fn restore_to_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<(), eyre::Report> { + // 1. Restore history from snapshot + self.history = checkpoint.history_snapshot.clone(); + + // 2. Clear any pending next message (uncommitted state) + self.next_message = None; + + // 3. Update valid history range + self.valid_history_range = (0, self.history.len()); + + Ok(()) } /// Swapping agent involves the following: diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index ec09b83ff2..ed8c92e9c0 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1338,7 +1338,7 @@ impl ChatSession { { let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; let start = std::time::Instant::now(); - let checkpoint_manager = match CheckpointManager::auto_init(os, &path).await { + let checkpoint_manager = match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { Ok(manager) => { execute!( self.stderr, @@ -2400,14 +2400,12 @@ impl ChatSession { None => tool.tool.display_name(), } }; - // Get history length before putting manager back - let history_len = self.conversation.history().len(); // Create checkpoint if let Err(e) = manager.create_checkpoint( &tag, &description, - history_len + 1, + &self.conversation.history().clone(), false, Some(tool.name.clone()), ) { @@ -2898,12 +2896,15 @@ impl ChatSession { |msg| truncate_message(&msg, CHECKPOINT_MESSAGE_MAX_LENGTH), ); - // Get history length before putting manager back - let history_len = self.conversation.history().len(); - // Create turn checkpoint let tag = manager.current_turn.to_string(); - if let Err(e) = manager.create_checkpoint(&tag, &description, history_len, true, None) { + if let Err(e) = manager.create_checkpoint( + &tag, + &description, + &self.conversation.history().clone(), + true, + None, + ) { execute!( self.stderr, style::SetForegroundColor(Color::Yellow), From e16541edd3a4fab767a3f8b42ad038a5764c405e Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 24 Sep 2025 16:06:08 -0700 Subject: [PATCH 27/31] disable tangent mode in checkpoint --- .../chat-cli/src/cli/chat/cli/checkpoint.rs | 20 +++++ crates/chat-cli/src/cli/chat/cli/tangent.rs | 20 +++++ crates/chat-cli/src/cli/chat/mod.rs | 85 +++++++++++++------ 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs index 2dd97135ec..f6a42bca91 100644 --- a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -83,6 +83,26 @@ With --hard: impl CheckpointSubcommand { pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { + // Check if in tangent mode - captures are disabled during tangent mode + if os + .database + .settings + .get_bool(Setting::EnabledTangentMode) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Disbale tangent mode with: q settings -d chat.enableTangentMode.\n\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + // Check if checkpoint is enabled if !os .database diff --git a/crates/chat-cli/src/cli/chat/cli/tangent.rs b/crates/chat-cli/src/cli/chat/cli/tangent.rs index 94184c4828..4856bc7404 100644 --- a/crates/chat-cli/src/cli/chat/cli/tangent.rs +++ b/crates/chat-cli/src/cli/chat/cli/tangent.rs @@ -63,6 +63,26 @@ impl TangentArgs { }); } + // Check if checkpoint is enabled + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Tangent mode is disabled while using checkpoint. Disbale checkpoint with: q settings -d chat.enableCheckpoint.\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + return Ok(ChatState::PromptUser { + skip_printing_tools: true, + }); + } + match self.subcommand { Some(TangentSubcommand::Tail) => { if session.conversation.is_in_tangent_mode() { diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index ed8c92e9c0..a74234e906 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1336,29 +1336,46 @@ impl ChatSession { .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) { - let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; - let start = std::time::Instant::now(); - let checkpoint_manager = match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { - Ok(manager) => { - execute!( - self.stderr, - style::Print( - format!( - "📷 Checkpoints are enabled! (took {:.2}s)\n\n", - start.elapsed().as_secs_f32() - ) - .blue() - .bold() - ) - )?; - Some(manager) - }, - Err(e) => { - execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; - None - }, - }; - self.conversation.checkpoint_manager = checkpoint_manager; + if os + .database + .settings + .get_bool(Setting::EnabledTangentMode) + .unwrap_or(false) + { + execute!( + self.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Disbale tangent mode with: q settings -d chat.enableTangentMode.\n\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + } else { + let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; + let start = std::time::Instant::now(); + let checkpoint_manager = + match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { + Ok(manager) => { + execute!( + self.stderr, + style::Print( + format!( + "📷 Checkpoints are enabled! (took {:.2}s)\n\n", + start.elapsed().as_secs_f32() + ) + .blue() + .bold() + ) + )?; + Some(manager) + }, + Err(e) => { + execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; + None + }, + }; + self.conversation.checkpoint_manager = checkpoint_manager; + } } if let Some(user_input) = self.initial_input.take() { @@ -2129,6 +2146,11 @@ impl ChatSession { .settings .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) + && !os + .database + .settings + .get_bool(Setting::EnabledTangentMode) + .unwrap_or(false) { if let Some(manager) = self.conversation.checkpoint_manager.as_mut() { if !manager.message_locked && self.pending_tool_index.is_none() { @@ -2228,6 +2250,11 @@ impl ChatSession { .settings .get_bool(Setting::IntrospectTangentMode) .unwrap_or(false) + && !os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) && !self.conversation.is_in_tangent_mode() && self .tool_uses @@ -2366,7 +2393,12 @@ impl ChatSession { .database .settings .get_bool(Setting::EnabledCheckpoint) - .unwrap_or(false); + .unwrap_or(false) + && !os + .database + .settings + .get_bool(Setting::EnabledTangentMode) + .unwrap_or(false); if invoke_result.is_err() || !enabled { String::new() } @@ -2884,6 +2916,11 @@ impl ChatSession { .settings .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) + && !os + .database + .settings + .get_bool(Setting::EnabledTangentMode) + .unwrap_or(false) { if let Some(mut manager) = self.conversation.checkpoint_manager.take() { if manager.tools_in_turn > 0 { From 049ea08d460c9d6a67cedf8061d067add16767bf Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 24 Sep 2025 16:46:32 -0700 Subject: [PATCH 28/31] fix cr --- .../chat-cli/src/cli/chat/cli/checkpoint.rs | 2 +- crates/chat-cli/src/cli/chat/cli/tangent.rs | 2 +- crates/chat-cli/src/cli/chat/mod.rs | 25 ++++++++++++------- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs index f6a42bca91..8f088f982b 100644 --- a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -94,7 +94,7 @@ impl CheckpointSubcommand { session.stderr, style::SetForegroundColor(Color::Yellow), style::Print( - "⚠️ Checkpoint is disabled while in tangent mode. Disbale tangent mode with: q settings -d chat.enableTangentMode.\n\n" + "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" ), style::SetForegroundColor(Color::Reset), )?; diff --git a/crates/chat-cli/src/cli/chat/cli/tangent.rs b/crates/chat-cli/src/cli/chat/cli/tangent.rs index 4856bc7404..96b16c446e 100644 --- a/crates/chat-cli/src/cli/chat/cli/tangent.rs +++ b/crates/chat-cli/src/cli/chat/cli/tangent.rs @@ -74,7 +74,7 @@ impl TangentArgs { session.stderr, style::SetForegroundColor(Color::Yellow), style::Print( - "⚠️ Tangent mode is disabled while using checkpoint. Disbale checkpoint with: q settings -d chat.enableCheckpoint.\n" + "⚠️ Tangent mode is disabled while using checkpoint. Disable checkpoint with: q settings -d chat.enableCheckpoint.\n" ), style::SetForegroundColor(Color::Reset), )?; diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index a74234e906..3b18853bc7 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -1346,7 +1346,7 @@ impl ChatSession { self.stderr, style::SetForegroundColor(Color::Yellow), style::Print( - "⚠️ Checkpoint is disabled while in tangent mode. Disbale tangent mode with: q settings -d chat.enableTangentMode.\n\n" + "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" ), style::SetForegroundColor(Color::Reset), )?; @@ -2388,7 +2388,7 @@ impl ChatSession { execute!(self.stdout, style::Print("\n"))?; // Handle checkpoint after tool execution - store tag for later display - let checkpoint_tag = { + let checkpoint_tag: Option = { let enabled = os .database .settings @@ -2400,7 +2400,7 @@ impl ChatSession { .get_bool(Setting::EnabledTangentMode) .unwrap_or(false); if invoke_result.is_err() || !enabled { - String::new() + None } // Take manager out temporarily to avoid borrow conflicts else if let Some(mut manager) = self.conversation.checkpoint_manager.take() { @@ -2442,20 +2442,20 @@ impl ChatSession { Some(tool.name.clone()), ) { debug!("Failed to create tool checkpoint: {}", e); - String::new() + None } else { manager.tools_in_turn += 1; - tag + Some(tag) } } else { - String::new() + None }; // Put manager back self.conversation.checkpoint_manager = Some(manager); tag } else { - String::new() + None } }; @@ -2502,8 +2502,15 @@ impl ChatSession { style::Print(format!(" ● Completed in {}s", tool_time)), style::SetForegroundColor(Color::Reset), )?; - if !checkpoint_tag.is_empty() { - execute!(self.stdout, style::Print(format!(" [{checkpoint_tag}]").blue().bold()))?; + if let Some(tag) = checkpoint_tag { + execute!( + self.stdout, + style::SetForegroundColor(Color::Blue), + style::SetAttribute(Attribute::Bold), + style::Print(format!(" [{tag}]")), + style::SetForegroundColor(Color::Reset), + style::SetAttribute(Attribute::Reset), + )?; } execute!(self.stdout, style::Print("\n\n"))?; From e479178b246bdff4a4883074515c73cda9071f4b Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Wed, 24 Sep 2025 16:56:28 -0700 Subject: [PATCH 29/31] nit: keep checkpoint name --- crates/chat-cli/src/cli/chat/conversation.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/chat-cli/src/cli/chat/conversation.rs b/crates/chat-cli/src/cli/chat/conversation.rs index 979475078b..f1c375e25b 100644 --- a/crates/chat-cli/src/cli/chat/conversation.rs +++ b/crates/chat-cli/src/cli/chat/conversation.rs @@ -883,7 +883,7 @@ Return only the JSON configuration, no additional text.", self.transcript.push_back(message); } - /// Restore conversation from a capture's history snapshot + /// Restore conversation from a checkpoint's history snapshot pub fn restore_to_checkpoint(&mut self, checkpoint: &Checkpoint) -> Result<(), eyre::Report> { // 1. Restore history from snapshot self.history = checkpoint.history_snapshot.clone(); From 3601482ee88b3c038928a00a6f707e545e543cc6 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Thu, 25 Sep 2025 15:35:22 -0700 Subject: [PATCH 30/31] allow both tangent & checkpoint enabled --- .../chat-cli/src/cli/chat/cli/checkpoint.rs | 31 +++---- .../chat-cli/src/cli/chat/cli/experiment.rs | 6 +- crates/chat-cli/src/cli/chat/cli/tangent.rs | 53 ++++++----- crates/chat-cli/src/cli/chat/mod.rs | 89 ++++++------------- 4 files changed, 79 insertions(+), 100 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs index 8f088f982b..634da119c3 100644 --- a/crates/chat-cli/src/cli/chat/cli/checkpoint.rs +++ b/crates/chat-cli/src/cli/chat/cli/checkpoint.rs @@ -83,38 +83,33 @@ With --hard: impl CheckpointSubcommand { pub async fn execute(self, os: &Os, session: &mut ChatSession) -> Result { - // Check if in tangent mode - captures are disabled during tangent mode - if os + // Check if checkpoint is enabled + if !os .database .settings - .get_bool(Setting::EnabledTangentMode) + .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) { execute!( session.stderr, - style::SetForegroundColor(Color::Yellow), - style::Print( - "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" - ), - style::SetForegroundColor(Color::Reset), + style::SetForegroundColor(Color::Red), + style::Print("\nCheckpoint is disabled. Enable it with: q settings chat.enableCheckpoint true\n"), + style::SetForegroundColor(Color::Reset) )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, }); } - // Check if checkpoint is enabled - if !os - .database - .settings - .get_bool(Setting::EnabledCheckpoint) - .unwrap_or(false) - { + // Check if in tangent mode - captures are disabled during tangent mode + if session.conversation.is_in_tangent_mode() { execute!( session.stderr, - style::SetForegroundColor(Color::Red), - style::Print("\nCheckpoint is disabled. Enable it with: q settings chat.enableCheckpoint true\n"), - style::SetForegroundColor(Color::Reset) + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" + ), + style::SetForegroundColor(Color::Reset), )?; return Ok(ChatState::PromptUser { skip_printing_tools: true, diff --git a/crates/chat-cli/src/cli/chat/cli/experiment.rs b/crates/chat-cli/src/cli/chat/cli/experiment.rs index 0e1a1cb466..675b03fb3b 100644 --- a/crates/chat-cli/src/cli/chat/cli/experiment.rs +++ b/crates/chat-cli/src/cli/chat/cli/experiment.rs @@ -52,7 +52,11 @@ static AVAILABLE_EXPERIMENTS: &[Experiment] = &[ }, Experiment { name: "Checkpoint", - description: "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)", + description: concat!( + "Enables workspace checkpoints to snapshot, list, expand, diff, and restore files (/checkpoint)\n", + " ", + "Cannot be used in tangent mode (to avoid mixing up conversation history)" + ), setting_key: Setting::EnabledCheckpoint, }, ]; diff --git a/crates/chat-cli/src/cli/chat/cli/tangent.rs b/crates/chat-cli/src/cli/chat/cli/tangent.rs index 96b16c446e..65165c84f3 100644 --- a/crates/chat-cli/src/cli/chat/cli/tangent.rs +++ b/crates/chat-cli/src/cli/chat/cli/tangent.rs @@ -63,28 +63,24 @@ impl TangentArgs { }); } - // Check if checkpoint is enabled - if os - .database - .settings - .get_bool(Setting::EnabledCheckpoint) - .unwrap_or(false) - { - execute!( - session.stderr, - style::SetForegroundColor(Color::Yellow), - style::Print( - "⚠️ Tangent mode is disabled while using checkpoint. Disable checkpoint with: q settings -d chat.enableCheckpoint.\n" - ), - style::SetForegroundColor(Color::Reset), - )?; - return Ok(ChatState::PromptUser { - skip_printing_tools: true, - }); - } - match self.subcommand { Some(TangentSubcommand::Tail) => { + // Check if checkpoint is enabled + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + } if session.conversation.is_in_tangent_mode() { let duration_seconds = session.conversation.get_tangent_duration_seconds().unwrap_or(0); session.conversation.exit_tangent_mode_with_tail(); @@ -126,6 +122,23 @@ impl TangentArgs { style::SetForegroundColor(Color::Reset) )?; } else { + // Check if checkpoint is enabled + if os + .database + .settings + .get_bool(Setting::EnabledCheckpoint) + .unwrap_or(false) + { + execute!( + session.stderr, + style::SetForegroundColor(Color::Yellow), + style::Print( + "⚠️ Checkpoint is disabled while in tangent mode. Please exit tangent mode if you want to use checkpoint.\n" + ), + style::SetForegroundColor(Color::Reset), + )?; + } + session.conversation.enter_tangent_mode(); // Get the configured tangent mode key for display diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 3b18853bc7..860ce6dd2d 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -462,7 +462,7 @@ const RESUME_TEXT: &str = color_print::cstr! {"Picking up where we left off. const CHANGELOG_MAX_SHOW_COUNT: i64 = 2; // Only show the model-related tip for now to make users aware of this feature. -const ROTATING_TIPS: [&str; 19] = [ +const ROTATING_TIPS: [&str; 20] = [ color_print::cstr! {"You can resume the last conversation from your current directory by launching with q chat --resume"}, color_print::cstr! {"Get notified whenever Q CLI finishes responding. @@ -497,6 +497,7 @@ const ROTATING_TIPS: [&str; 19] = [ color_print::cstr! {"Use /tangent or ctrl + t (customizable) to start isolated conversations ( ↯ ) that don't affect your main chat history"}, color_print::cstr! {"Ask me directly about my capabilities! Try questions like \"What can you do?\" or \"Can you save conversations?\""}, color_print::cstr! {"Stay up to date with the latest features and improvements! Use /changelog to see what's new in Amazon Q CLI"}, + color_print::cstr! {"Enable workspace checkpoints to snapshot & restore changes. Just run q settings chat.enableCheckpoint true"}, ]; const GREETING_BREAK_POINT: usize = 80; @@ -1336,46 +1337,29 @@ impl ChatSession { .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) { - if os - .database - .settings - .get_bool(Setting::EnabledTangentMode) - .unwrap_or(false) - { - execute!( - self.stderr, - style::SetForegroundColor(Color::Yellow), - style::Print( - "⚠️ Checkpoint is disabled while in tangent mode. Disable tangent mode with: q settings -d chat.enableTangentMode.\n\n" - ), - style::SetForegroundColor(Color::Reset), - )?; - } else { - let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; - let start = std::time::Instant::now(); - let checkpoint_manager = - match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { - Ok(manager) => { - execute!( - self.stderr, - style::Print( - format!( - "📷 Checkpoints are enabled! (took {:.2}s)\n\n", - start.elapsed().as_secs_f32() - ) - .blue() - .bold() - ) - )?; - Some(manager) - }, - Err(e) => { - execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; - None - }, - }; - self.conversation.checkpoint_manager = checkpoint_manager; - } + let path = get_shadow_repo_dir(os, self.conversation.conversation_id().to_string())?; + let start = std::time::Instant::now(); + let checkpoint_manager = match CheckpointManager::auto_init(os, &path, self.conversation.history()).await { + Ok(manager) => { + execute!( + self.stderr, + style::Print( + format!( + "📷 Checkpoints are enabled! (took {:.2}s)\n\n", + start.elapsed().as_secs_f32() + ) + .blue() + .bold() + ) + )?; + Some(manager) + }, + Err(e) => { + execute!(self.stderr, style::Print(format!("{e}\n\n").blue()))?; + None + }, + }; + self.conversation.checkpoint_manager = checkpoint_manager; } if let Some(user_input) = self.initial_input.take() { @@ -2146,11 +2130,7 @@ impl ChatSession { .settings .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) - && !os - .database - .settings - .get_bool(Setting::EnabledTangentMode) - .unwrap_or(false) + && !self.conversation.is_in_tangent_mode() { if let Some(manager) = self.conversation.checkpoint_manager.as_mut() { if !manager.message_locked && self.pending_tool_index.is_none() { @@ -2250,11 +2230,6 @@ impl ChatSession { .settings .get_bool(Setting::IntrospectTangentMode) .unwrap_or(false) - && !os - .database - .settings - .get_bool(Setting::EnabledCheckpoint) - .unwrap_or(false) && !self.conversation.is_in_tangent_mode() && self .tool_uses @@ -2394,11 +2369,7 @@ impl ChatSession { .settings .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) - && !os - .database - .settings - .get_bool(Setting::EnabledTangentMode) - .unwrap_or(false); + && !self.conversation.is_in_tangent_mode(); if invoke_result.is_err() || !enabled { None } @@ -2923,11 +2894,7 @@ impl ChatSession { .settings .get_bool(Setting::EnabledCheckpoint) .unwrap_or(false) - && !os - .database - .settings - .get_bool(Setting::EnabledTangentMode) - .unwrap_or(false) + && !self.conversation.is_in_tangent_mode() { if let Some(mut manager) = self.conversation.checkpoint_manager.take() { if manager.tools_in_turn > 0 { From a313dfdc29ef7f4e5a284e6912d151d6377dc908 Mon Sep 17 00:00:00 2001 From: YIFAN LIU Date: Thu, 25 Sep 2025 15:45:30 -0700 Subject: [PATCH 31/31] ci --- crates/chat-cli/src/cli/chat/mod.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/chat-cli/src/cli/chat/mod.rs b/crates/chat-cli/src/cli/chat/mod.rs index 66b39835f0..fcdb8b30ef 100644 --- a/crates/chat-cli/src/cli/chat/mod.rs +++ b/crates/chat-cli/src/cli/chat/mod.rs @@ -19,7 +19,6 @@ mod token_counter; pub mod tool_manager; pub mod tools; pub mod util; - use std::borrow::Cow; use std::collections::{ HashMap, @@ -2865,7 +2864,10 @@ impl ChatSession { self.stdout, style::Print("\n\n"), style::SetForegroundColor(Color::Yellow), - style::Print(format!("Tool validation failed: {}\n Retrying the request...", error_message)), + style::Print(format!( + "Tool validation failed: {}\n Retrying the request...", + error_message + )), style::ResetColor, style::Print("\n"), );