From f5b78b6a18541f893618cf63bc9d51b8900530ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:27:38 +0000 Subject: [PATCH 1/5] Initial plan From 57ac8548f11aeb79249bbcb319c25efe51e3234d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:32:54 +0000 Subject: [PATCH 2/5] Add context snapshot support to Conversation domain model Co-Authored-By: Paws Co-authored-by: manthanabc <48511543+manthanabc@users.noreply.github.com> --- crates/paws_domain/src/conversation.rs | 200 +++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/crates/paws_domain/src/conversation.rs b/crates/paws_domain/src/conversation.rs index 7d7353d4..411f6afc 100644 --- a/crates/paws_domain/src/conversation.rs +++ b/crates/paws_domain/src/conversation.rs @@ -46,6 +46,20 @@ pub struct Conversation { pub context: Option, pub metrics: Metrics, pub metadata: MetaData, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub context_history: Vec, +} + +/// Represents a snapshot of the conversation context at a specific point in time. +/// Used to implement undo functionality by storing previous states. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ContextSnapshot { + /// The context state at this snapshot + pub context: Context, + /// When this snapshot was created + pub timestamp: DateTime, + /// A brief summary of what this snapshot represents (e.g., the user's message) + pub summary: String, } #[derive(Debug, Setters, Serialize, Deserialize, Clone)] @@ -71,6 +85,7 @@ impl Conversation { metadata: MetaData::new(created_at), title: None, context: None, + context_history: Vec::new(), } } /// Creates a new conversation with a new conversation ID. @@ -128,4 +143,189 @@ impl Conversation { pub fn accumulated_cost(&self) -> Option { self.accumulated_usage().and_then(|usage| usage.cost) } + + /// Creates a snapshot of the current context state. + /// + /// # Arguments + /// * `summary` - A brief description of this snapshot (e.g., the user's message) + /// + /// # Returns + /// `Some(ContextSnapshot)` if there is a context to snapshot, `None` otherwise + pub fn create_snapshot(&self, summary: String) -> Option { + self.context.as_ref().map(|ctx| ContextSnapshot { + context: ctx.clone(), + timestamp: Utc::now(), + summary, + }) + } + + /// Saves a snapshot of the current context to the history. + /// Limits the history to a maximum of 50 snapshots to prevent unbounded growth. + /// + /// # Arguments + /// * `summary` - A brief description of this snapshot + pub fn save_snapshot(&mut self, summary: String) { + if let Some(snapshot) = self.create_snapshot(summary) { + const MAX_HISTORY: usize = 50; + + // Add the new snapshot + self.context_history.push(snapshot); + + // Keep only the last MAX_HISTORY snapshots + if self.context_history.len() > MAX_HISTORY { + self.context_history.drain(0..self.context_history.len() - MAX_HISTORY); + } + } + } + + /// Reverts the conversation to the previous snapshot state. + /// + /// # Returns + /// `true` if undo was successful, `false` if there's no history to undo + pub fn undo(&mut self) -> bool { + if let Some(snapshot) = self.context_history.pop() { + self.context = Some(snapshot.context); + true + } else { + false + } + } + + /// Checks if undo is available. + /// + /// # Returns + /// `true` if there are snapshots in the history that can be undone + pub fn can_undo(&self) -> bool { + !self.context_history.is_empty() + } + + /// Returns the summary of the last snapshot, if available. + pub fn last_snapshot_summary(&self) -> Option<&str> { + self.context_history.last().map(|s| s.summary.as_str()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + use crate::{Context, Role, TextMessage}; + + #[test] + fn test_new_conversation_has_empty_history() { + let fixture = Conversation::generate(); + + let actual = fixture.context_history.len(); + let expected = 0; + + assert_eq!(actual, expected); + assert!(!fixture.can_undo()); + } + + #[test] + fn test_save_snapshot_creates_history() { + let mut fixture = Conversation::generate(); + let context = Context::default() + .add_message(TextMessage::new(Role::User, "Hello")); + fixture.context = Some(context); + + fixture.save_snapshot("First message".to_string()); + + assert_eq!(fixture.context_history.len(), 1); + assert!(fixture.can_undo()); + assert_eq!(fixture.last_snapshot_summary(), Some("First message")); + } + + #[test] + fn test_undo_reverts_to_previous_state() { + let mut fixture = Conversation::generate(); + + // Initial state with first message + let context1 = Context::default() + .add_message(TextMessage::new(Role::User, "Hello")); + fixture.context = Some(context1.clone()); + fixture.save_snapshot("First message".to_string()); + + // Second state with additional message + let context2 = context1 + .add_message(TextMessage::new(Role::Assistant, "Hi there")); + fixture.context = Some(context2); + + // Undo should revert to first state + let undo_result = fixture.undo(); + + assert!(undo_result); + let actual = fixture.context.as_ref().unwrap().messages.len(); + let expected = 1; + assert_eq!(actual, expected); + assert!(!fixture.can_undo()); + } + + #[test] + fn test_undo_without_history_returns_false() { + let mut fixture = Conversation::generate(); + + let actual = fixture.undo(); + let expected = false; + + assert_eq!(actual, expected); + } + + #[test] + fn test_snapshot_history_limited_to_50() { + let mut fixture = Conversation::generate(); + let context = Context::default() + .add_message(TextMessage::new(Role::User, "Test")); + fixture.context = Some(context); + + // Add 60 snapshots + for i in 0..60 { + fixture.save_snapshot(format!("Snapshot {}", i)); + } + + // Should only keep last 50 + let actual = fixture.context_history.len(); + let expected = 50; + assert_eq!(actual, expected); + + // First snapshot should be "Snapshot 10" (60 - 50) + let actual_first = fixture.context_history.first().unwrap().summary.as_str(); + let expected_first = "Snapshot 10"; + assert_eq!(actual_first, expected_first); + } + + #[test] + fn test_multiple_undo_operations() { + let mut fixture = Conversation::generate(); + + // Create 3 states + let context1 = Context::default() + .add_message(TextMessage::new(Role::User, "Message 1")); + fixture.context = Some(context1.clone()); + fixture.save_snapshot("State 1".to_string()); + + let context2 = context1 + .add_message(TextMessage::new(Role::Assistant, "Response 1")); + fixture.context = Some(context2); + fixture.save_snapshot("State 2".to_string()); + + let context3 = Context::default() + .add_message(TextMessage::new(Role::User, "Message 1")) + .add_message(TextMessage::new(Role::Assistant, "Response 1")) + .add_message(TextMessage::new(Role::User, "Message 2")); + fixture.context = Some(context3); + + // First undo: from 3 messages to 2 + assert!(fixture.undo()); + assert_eq!(fixture.context.as_ref().unwrap().messages.len(), 2); + + // Second undo: from 2 messages to 1 + assert!(fixture.undo()); + assert_eq!(fixture.context.as_ref().unwrap().messages.len(), 1); + + // No more history + assert!(!fixture.can_undo()); + assert!(!fixture.undo()); + } } From 2434e3ec6068f2be69e51cf3f1c851cf1132a730 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:35:04 +0000 Subject: [PATCH 3/5] Implement snapshot capture in orchestrator Co-Authored-By: Paws Co-authored-by: manthanabc <48511543+manthanabc@users.noreply.github.com> --- crates/paws_app/src/orch.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/paws_app/src/orch.rs b/crates/paws_app/src/orch.rs index 4f8dc07e..2c547fe9 100644 --- a/crates/paws_app/src/orch.rs +++ b/crates/paws_app/src/orch.rs @@ -228,6 +228,11 @@ impl Orchestrator { // TODO: Move into app.rs let title = self.generate_title(model_id.clone()); + // Save initial snapshot before starting the interaction + // This allows undoing the entire interaction + let snapshot_summary = extract_user_message_summary(&event); + self.conversation.save_snapshot(snapshot_summary); + while !should_yield { // Set context for the current loop iteration self.conversation.context = Some(context.clone()); @@ -418,3 +423,26 @@ impl Orchestrator { } } } + +/// Extracts a brief summary from an event's user message for snapshot purposes. +/// +/// # Arguments +/// * `event` - The event containing the user message +/// +/// # Returns +/// A string summary, truncated to 100 characters +fn extract_user_message_summary(event: &Event) -> String { + event + .value + .as_ref() + .and_then(|v| v.as_user_prompt()) + .map(|prompt| { + let summary = prompt.as_str().trim(); + if summary.len() > 100 { + format!("{}...", &summary[..97]) + } else { + summary.to_string() + } + }) + .unwrap_or_else(|| "User interaction".to_string()) +} From 589df19e3bd95cdbb0df55b505e0e181313cc191 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:39:38 +0000 Subject: [PATCH 4/5] Expose undo command in UI and API Co-Authored-By: Paws Co-authored-by: manthanabc <48511543+manthanabc@users.noreply.github.com> --- crates/paws_api/src/api.rs | 12 ++++++ crates/paws_api/src/paws_api.rs | 4 ++ crates/paws_app/src/services.rs | 12 ++++++ crates/paws_main/src/model.rs | 6 +++ crates/paws_main/src/ui.rs | 48 ++++++++++++++++++++++++ crates/paws_services/src/conversation.rs | 7 ++++ 6 files changed, 89 insertions(+) diff --git a/crates/paws_api/src/api.rs b/crates/paws_api/src/api.rs index 215b4538..89e83c7a 100644 --- a/crates/paws_api/src/api.rs +++ b/crates/paws_api/src/api.rs @@ -70,6 +70,18 @@ pub trait API: Sync + Send { /// Returns an error if the operation fails async fn delete_conversation(&self, conversation_id: &ConversationId) -> Result<()>; + /// Undoes the last interaction in a conversation by reverting to the previous snapshot. + /// + /// # Arguments + /// * `conversation_id` - The ID of the conversation to undo + /// + /// # Returns + /// `true` if the undo was successful, `false` if there were no snapshots to undo + /// + /// # Errors + /// Returns an error if the conversation doesn't exist or the operation fails + async fn undo_conversation(&self, conversation_id: &ConversationId) -> Result; + /// Compacts the context of the main agent for the given conversation and /// persists it. Returns metrics about the compaction (original vs. /// compacted tokens and messages). diff --git a/crates/paws_api/src/paws_api.rs b/crates/paws_api/src/paws_api.rs index f255422c..ad99027a 100644 --- a/crates/paws_api/src/paws_api.rs +++ b/crates/paws_api/src/paws_api.rs @@ -151,6 +151,10 @@ impl anyhow::Result { + self.services.undo_conversation(conversation_id).await + } + async fn execute_shell_command( &self, command: &str, diff --git a/crates/paws_app/src/services.rs b/crates/paws_app/src/services.rs index 9500f666..45b0db4d 100644 --- a/crates/paws_app/src/services.rs +++ b/crates/paws_app/src/services.rs @@ -222,6 +222,12 @@ pub trait ConversationService: Send + Sync { /// Permanently deletes a conversation async fn delete_conversation(&self, conversation_id: &ConversationId) -> anyhow::Result<()>; + + /// Undoes the last interaction by reverting to a previous snapshot + /// + /// # Returns + /// `true` if undo was successful, `false` if there were no snapshots to undo + async fn undo_conversation(&self, conversation_id: &ConversationId) -> anyhow::Result; } #[async_trait::async_trait] @@ -573,6 +579,12 @@ impl ConversationService for I { .delete_conversation(conversation_id) .await } + + async fn undo_conversation(&self, conversation_id: &ConversationId) -> anyhow::Result { + self.conversation_service() + .undo_conversation(conversation_id) + .await + } } #[async_trait::async_trait] impl ProviderService for I { diff --git a/crates/paws_main/src/model.rs b/crates/paws_main/src/model.rs index 2d1f1271..d82f427d 100644 --- a/crates/paws_main/src/model.rs +++ b/crates/paws_main/src/model.rs @@ -198,6 +198,7 @@ impl PawsCommandManager { | "login" | "logout" | "retry" + | "undo" | "conversations" | "list" ) @@ -375,6 +376,7 @@ impl PawsCommandManager { "/login" => Ok(SlashCommand::Login), "/logout" => Ok(SlashCommand::Logout), "/retry" => Ok(SlashCommand::Retry), + "/undo" => Ok(SlashCommand::Undo), "/resume" => Ok(SlashCommand::Resume), "/conversation" | "/conversations" => Ok(SlashCommand::Conversations), @@ -508,6 +510,9 @@ pub enum SlashCommand { /// Retry without modifying model context #[strum(props(usage = "Retry the last command"))] Retry, + /// Undo the last interaction and revert to previous state + #[strum(props(usage = "Undo the last interaction"))] + Undo, /// Resume the last conversation or a specific conversation #[strum(props(usage = "Resume the last conversation or a specific conversation"))] Resume, @@ -558,6 +563,7 @@ impl SlashCommand { SlashCommand::Login => "login", SlashCommand::Logout => "logout", SlashCommand::Retry => "retry", + SlashCommand::Undo => "undo", SlashCommand::Resume => "resume", SlashCommand::Conversations => "conversation", SlashCommand::Delete => "delete", diff --git a/crates/paws_main/src/ui.rs b/crates/paws_main/src/ui.rs index 4a5b54a1..da118176 100644 --- a/crates/paws_main/src/ui.rs +++ b/crates/paws_main/src/ui.rs @@ -1700,6 +1700,9 @@ impl A + Send + Sync> UI { self.spinner.start(None)?; self.on_message(None).await?; } + SlashCommand::Undo => { + self.on_undo().await?; + } SlashCommand::AgentSwitch(agent_id) => { // Validate that the agent exists by checking against loaded agents let agents = self.api.get_agents().await?; @@ -1765,6 +1768,51 @@ impl A + Send + Sync> UI { Ok(()) } + /// Undoes the last interaction by reverting to a previous snapshot + async fn on_undo(&mut self) -> anyhow::Result<()> { + let conversation_id = self.init_conversation().await?; + + // Get the current conversation + let conversation = self + .api + .conversation(&conversation_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Conversation not found"))?; + + // Check if undo is available + if !conversation.can_undo() { + self.writeln_title(TitleFormat::error("No more actions to undo"))?; + return Ok(()); + } + + // Get the summary of what will be undone + let summary = conversation + .last_snapshot_summary() + .unwrap_or("previous interaction"); + + // Perform the undo + let success = self.api.undo_conversation(&conversation_id).await?; + + if success { + self.writeln_title(TitleFormat::action(format!( + "Undone: {summary}" + )))?; + + // Refresh the conversation display + let updated_conversation = self + .api + .conversation(&conversation_id) + .await? + .ok_or_else(|| anyhow::anyhow!("Conversation not found after undo"))?; + + self.on_show_conv_info(updated_conversation).await?; + } else { + self.writeln_title(TitleFormat::error("Failed to undo"))?; + } + + Ok(()) + } + /// Select a model from the available models /// Returns Some(ModelId) if a model was selected, or None if selection was /// canceled diff --git a/crates/paws_services/src/conversation.rs b/crates/paws_services/src/conversation.rs index 43adc12c..bb8b5828 100644 --- a/crates/paws_services/src/conversation.rs +++ b/crates/paws_services/src/conversation.rs @@ -66,4 +66,11 @@ impl ConversationService for PawsConversationService< .delete_conversation(conversation_id) .await } + + async fn undo_conversation(&self, conversation_id: &ConversationId) -> Result { + self.modify_conversation(conversation_id, |conversation| { + conversation.undo() + }) + .await + } } From 5f9a6b73f6a45878bbb0204d6ea0cbde59d6c913 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 30 Jan 2026 06:42:52 +0000 Subject: [PATCH 5/5] Update README and add undo feature documentation Co-Authored-By: Paws Co-authored-by: manthanabc <48511543+manthanabc@users.noreply.github.com> --- README.md | 2 +- UNDO_FEATURE.md | 100 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 UNDO_FEATURE.md diff --git a/README.md b/README.md index 22da4dd6..db71fbab 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Difference so far- - [WIP] Async input Planned - - - [o] Undo/ Redo controll + - [X] Undo/ Redo controll - [o] Background tasks - [o] Always visible prompt and enque pending tasks - [e] Parallel tasks diff --git a/UNDO_FEATURE.md b/UNDO_FEATURE.md new file mode 100644 index 00000000..b49e6c16 --- /dev/null +++ b/UNDO_FEATURE.md @@ -0,0 +1,100 @@ +# Undo System + +The undo system allows you to revert the last interaction and restore the conversation to its previous state. + +## How It Works + +1. **Automatic Snapshots**: Before each user interaction, Paws automatically creates a snapshot of the conversation context. +2. **Snapshot History**: Up to 50 snapshots are stored per conversation to allow multiple undo operations. +3. **Snapshot Cleanup**: When the limit is reached, the oldest snapshots are automatically removed to maintain performance. + +## Usage + +### Undo Command + +``` +/undo +``` + +Use the `/undo` command to revert the last interaction. This will: +- Restore the conversation context to the state before the last interaction +- Remove all messages, tool calls, and results from that interaction +- Display a confirmation message showing what was undone + +### Examples + +``` +> Can you add a new feature to the codebase? +< [AI response with code changes] + +> /undo +Undone: Can you add a new feature to the codebase? +[Conversation reverted to state before the feature request] +``` + +### Multiple Undo Operations + +You can call `/undo` multiple times to step back through your conversation history: + +``` +> First question +< Answer 1 + +> Second question +< Answer 2 + +> Third question +< Answer 3 + +> /undo +Undone: Third question +[Back to after Answer 2] + +> /undo +Undone: Second question +[Back to after Answer 1] +``` + +### When Undo is Not Available + +If there are no snapshots to undo (i.e., at the start of a conversation), you'll see: + +``` +> /undo +No more actions to undo +``` + +## Technical Details + +- **Snapshot Limit**: 50 snapshots per conversation +- **Storage**: Snapshots are persisted with the conversation in the database +- **Performance**: Snapshots are lightweight and contain only the conversation context state +- **Summary**: Each snapshot includes a brief summary (first 100 characters of the user message) for display purposes + +## Implementation + +The undo system is implemented through: + +1. **Domain Model** (`Conversation` struct): + - `context_history: Vec` - stores snapshot history + - `save_snapshot(summary)` - creates and saves a snapshot + - `undo()` - reverts to the previous snapshot + - `can_undo()` - checks if undo is available + +2. **Orchestrator** (in `paws_app`): + - Automatically creates snapshots before processing each user interaction + +3. **Services** (`ConversationService`): + - `undo_conversation(&conversation_id)` - performs the undo operation + +4. **UI** (`SlashCommand::Undo`): + - `/undo` command to trigger undo from the interactive prompt + +## Future Enhancements + +Potential future improvements: +- Redo functionality to reverse an undo +- Named snapshots for bookmarking important conversation states +- Snapshot browsing to see and select from history +- Configurable snapshot limit +- Differential snapshots to reduce memory usage for large conversations