diff --git a/Cargo.lock b/Cargo.lock index 75bdf2ca..0205ea43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1357,7 +1357,6 @@ dependencies = [ "sandbox", "serde", "serde_json", - "shell-words", "similar", "smallvec", "tempfile", @@ -6772,12 +6771,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shell-words" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" - [[package]] name = "shlex" version = "1.3.0" diff --git a/crates/code_assistant/Cargo.toml b/crates/code_assistant/Cargo.toml index af5aac1f..712692dd 100644 --- a/crates/code_assistant/Cargo.toml +++ b/crates/code_assistant/Cargo.toml @@ -51,7 +51,6 @@ async-trait = "0.1" dotenv = "0.15" dirs = "5.0" md5 = "0.7.0" -shell-words = "1.1" # Date and time handling chrono = { version = "0.4", features = ["serde"] } diff --git a/crates/code_assistant/assets/icons/panel_left_close.svg b/crates/code_assistant/assets/icons/panel_left_close.svg new file mode 100644 index 00000000..c5bda10c --- /dev/null +++ b/crates/code_assistant/assets/icons/panel_left_close.svg @@ -0,0 +1 @@ + diff --git a/crates/code_assistant/assets/icons/panel_left_open.svg b/crates/code_assistant/assets/icons/panel_left_open.svg new file mode 100644 index 00000000..e10acec9 --- /dev/null +++ b/crates/code_assistant/assets/icons/panel_left_open.svg @@ -0,0 +1 @@ + diff --git a/crates/code_assistant/src/acp/terminal_executor.rs b/crates/code_assistant/src/acp/terminal_executor.rs index f0016237..f61ce3f9 100644 --- a/crates/code_assistant/src/acp/terminal_executor.rs +++ b/crates/code_assistant/src/acp/terminal_executor.rs @@ -1,7 +1,6 @@ use agent_client_protocol::{self as acp, Client}; use anyhow::{anyhow, Result}; use async_trait::async_trait; -use shell_words::split as split_command_line; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; @@ -58,18 +57,6 @@ impl ACPTerminalCommandExecutor { default_timeout: DEFAULT_TIMEOUT, } } - - fn parse_command_line(command_line: &str) -> Result<(String, Vec)> { - let mut parts = split_command_line(command_line) - .map_err(|e| anyhow!("Failed to parse command line '{command_line}': {e}"))? - .into_iter(); - - let command = parts - .next() - .ok_or_else(|| anyhow!("Command line is empty"))?; - let args = parts.collect(); - Ok((command, args)) - } } #[async_trait] @@ -91,11 +78,6 @@ impl CommandExecutor for ACPTerminalCommandExecutor { callback: Option<&dyn StreamingCallback>, sandbox_request: Option<&SandboxCommandRequest>, ) -> Result { - let (command, args) = match Self::parse_command_line(command_line) { - Ok(parsed) => parsed, - Err(err) => return Err(err), - }; - let sender = match terminal_worker_sender() { Some(sender) => sender, None => { @@ -109,8 +91,7 @@ impl CommandExecutor for ACPTerminalCommandExecutor { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); let request = TerminalExecuteRequest { session_id: self.session_id.clone(), - command, - args, + command_line: command_line.to_string(), cwd: working_dir.cloned(), timeout: self.default_timeout, streaming: callback.is_some(), @@ -157,8 +138,7 @@ impl CommandExecutor for ACPTerminalCommandExecutor { #[derive(Debug)] struct TerminalExecuteRequest { session_id: acp::SessionId, - command: String, - args: Vec, + command_line: String, cwd: Option, timeout: Duration, streaming: bool, @@ -204,18 +184,19 @@ async fn run_command( ) -> Result { let TerminalExecuteRequest { session_id, - command, - args, + command_line, cwd, timeout, streaming, .. } = request; + // Pass the complete command line as the command parameter with empty args. + // This avoids escaping issues on the Zed side when args are passed separately. let create_request = acp::CreateTerminalRequest { session_id: session_id.clone(), - command, - args, + command: command_line, + args: Vec::new(), env: Vec::new(), cwd, output_byte_limit: Some(OUTPUT_BYTE_LIMIT), @@ -369,23 +350,3 @@ async fn wait_for_terminal_completion( output: output_response.output, }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_command_line_handles_quotes() { - let (command, args) = - ACPTerminalCommandExecutor::parse_command_line("cargo test --package \"my crate\"") - .unwrap(); - assert_eq!(command, "cargo"); - assert_eq!(args, vec!["test", "--package", "my crate"]); - } - - #[test] - fn parse_command_line_errors_on_empty_input() { - let err = ACPTerminalCommandExecutor::parse_command_line("").unwrap_err(); - assert!(err.to_string().contains("empty")); - } -} diff --git a/crates/code_assistant/src/acp/ui.rs b/crates/code_assistant/src/acp/ui.rs index e9da2562..d4d2fb7a 100644 --- a/crates/code_assistant/src/acp/ui.rs +++ b/crates/code_assistant/src/acp/ui.rs @@ -668,9 +668,39 @@ impl UserInterface for ACPUserUI { ); } + // Resource events - could be used for "follow mode" in ACP + UiEvent::ResourceLoaded { project, path } => { + tracing::trace!( + "ACPUserUI: ResourceLoaded - project: {}, path: {}", + project, + path.display() + ); + // TODO: Could emit follow mode updates here + } + UiEvent::ResourceWritten { project, path } => { + tracing::trace!( + "ACPUserUI: ResourceWritten - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::DirectoryListed { project, path } => { + tracing::trace!( + "ACPUserUI: DirectoryListed - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::ResourceDeleted { project, path } => { + tracing::trace!( + "ACPUserUI: ResourceDeleted - project: {}, path: {}", + project, + path.display() + ); + } + // Events that don't translate to ACP - UiEvent::UpdateMemory { .. } - | UiEvent::SetMessages { .. } + UiEvent::SetMessages { .. } | UiEvent::DisplayCompactionSummary { .. } | UiEvent::StreamingStarted(_) | UiEvent::StreamingStopped { .. } diff --git a/crates/code_assistant/src/agent/runner.rs b/crates/code_assistant/src/agent/runner.rs index 8c803e5d..96fae5d8 100644 --- a/crates/code_assistant/src/agent/runner.rs +++ b/crates/code_assistant/src/agent/runner.rs @@ -45,7 +45,6 @@ enum LoopFlow { } pub struct Agent { - working_memory: WorkingMemory, plan: PlanState, llm_provider: Box, session_config: SessionConfig, @@ -77,6 +76,10 @@ pub struct Agent { enable_naming_reminders: bool, // Shared pending message with SessionInstance pending_message_ref: Option>>>, + // File trees for projects (used in system prompt) + file_trees: HashMap, + // Available project names (used in system prompt) + available_projects: Vec, } const CONTEXT_USAGE_THRESHOLD: f32 = 0.8; @@ -111,7 +114,6 @@ impl Agent { } = components; let mut this = Self { - working_memory: WorkingMemory::default(), plan: PlanState::default(), llm_provider, session_config, @@ -132,6 +134,8 @@ impl Agent { enable_naming_reminders: true, // Enabled by default pending_message_ref: None, model_hint: None, + file_trees: HashMap::new(), + available_projects: Vec::new(), }; if this.session_config.use_diff_blocks { this.tool_scope = ToolScope::AgentWithDiffBlocks; @@ -267,13 +271,21 @@ impl Agent { "saving {} messages to persistence", self.message_history.len() ); + if let Some(session_id) = &self.session_id { + // Create a WorkingMemory for backward-compatible persistence + // File trees are regenerated on load, so we only persist available_projects + let working_memory = WorkingMemory { + available_projects: self.available_projects.clone(), + ..Default::default() + }; + let session_state = crate::session::SessionState { session_id: session_id.clone(), name: self.session_name.clone(), messages: self.message_history.clone(), tool_executions: self.tool_executions.clone(), - working_memory: self.working_memory.clone(), + working_memory, plan: self.plan.clone(), config: self.session_config.clone(), next_request_id: Some(self.next_request_id), @@ -370,19 +382,14 @@ impl Agent { debug!("Agent iteration complete - waiting for next user message"); return Ok(()); } + LoopFlow::Continue => { if !tool_requests.is_empty() { // Tools were requested, manage their execution let flow = self.manage_tool_execution(&tool_requests).await?; - // Save state and update memory after tool executions + // Save state after tool executions self.save_state()?; - let _ = self - .ui - .send_event(UiEvent::UpdateMemory { - memory: self.working_memory.clone(), - }) - .await; match flow { LoopFlow::Continue => { /* Continue to the next iteration */ } @@ -425,7 +432,6 @@ impl Agent { self.message_history.len() ); self.tool_executions = session_state.tool_executions; - self.working_memory = session_state.working_memory; self.plan = session_state.plan.clone(); self.session_config = session_state.config; if self.session_config.use_diff_blocks { @@ -458,15 +464,12 @@ impl Agent { + 1 }); - // Restore working memory file trees and project state - self.init_working_memory_projects()?; + // Restore available projects from persisted working memory + // (backward compatibility with existing sessions) + self.available_projects = session_state.working_memory.available_projects.clone(); - let _ = self - .ui - .send_event(UiEvent::UpdateMemory { - memory: self.working_memory.clone(), - }) - .await; + // Refresh project information from project manager (regenerates file trees) + self.init_projects()?; let _ = self .ui @@ -539,18 +542,18 @@ impl Agent { } #[allow(dead_code)] - pub fn init_working_memory(&mut self) -> Result<()> { + pub fn init_project_context(&mut self) -> Result<()> { // Initialize empty structures for multi-project support - self.working_memory.file_trees = HashMap::new(); - self.working_memory.available_projects = Vec::new(); + self.file_trees = HashMap::new(); + self.available_projects = Vec::new(); // Reset the initial project self.session_config.initial_project = String::new(); - self.init_working_memory_projects() + self.init_projects() } - fn init_working_memory_projects(&mut self) -> Result<()> { + fn init_projects(&mut self) -> Result<()> { // If a path was provided in args, add it as a temporary project if let Some(path) = &self.session_config.init_path { // Add as temporary project and get its name @@ -565,23 +568,16 @@ impl Agent { .get_explorer_for_project(&project_name)?; let tree = explorer.create_initial_tree(2)?; // Limited depth for initial tree - // Store in working memory - self.working_memory - .file_trees - .insert(project_name.clone(), tree); + // Store file tree as string for system prompt + self.file_trees + .insert(project_name.clone(), tree.to_string()); } // Load all available projects let all_projects = self.project_manager.get_projects()?; for project_name in all_projects.keys() { - if !self - .working_memory - .available_projects - .contains(project_name) - { - self.working_memory - .available_projects - .push(project_name.clone()); + if !self.available_projects.contains(project_name) { + self.available_projects.push(project_name.clone()); } } @@ -690,7 +686,7 @@ impl Agent { pub async fn start_with_task(&mut self, task: String) -> Result<()> { debug!("Starting agent with task: {}", task); - self.init_working_memory()?; + self.init_project_context()?; self.message_history.clear(); self.ui @@ -703,14 +699,6 @@ impl Agent { // Create the initial user message self.append_message(Message::new_user(task.clone()))?; - // Notify UI of initial working memory - let _ = self - .ui - .send_event(UiEvent::UpdateMemory { - memory: self.working_memory.clone(), - }) - .await; - self.run_single_iteration().await } @@ -745,20 +733,16 @@ impl Agent { )); // Add file tree for the initial project if available - if let Some(tree) = self - .working_memory - .file_trees - .get(&self.session_config.initial_project) - { + if let Some(tree) = self.file_trees.get(&self.session_config.initial_project) { project_info.push_str("### File Structure:\n"); project_info.push_str(&format!("```\n{tree}\n```\n\n")); } } // Add information about available projects - if !self.working_memory.available_projects.is_empty() { + if !self.available_projects.is_empty() { project_info.push_str("## Available Projects:\n"); - for project in &self.working_memory.available_projects { + for project in &self.available_projects { project_info.push_str(&format!("- {project}\n")); } } @@ -1440,7 +1424,6 @@ impl Agent { let mut context = ToolContext { project_manager: self.project_manager.as_ref(), command_executor: self.command_executor.as_ref(), - working_memory: Some(&mut self.working_memory), plan: Some(&mut self.plan), ui: Some(self.ui.as_ref()), tool_id: Some(tool_request.id.clone()), diff --git a/crates/code_assistant/src/mcp/handler.rs b/crates/code_assistant/src/mcp/handler.rs index 34f809aa..8a1ad144 100644 --- a/crates/code_assistant/src/mcp/handler.rs +++ b/crates/code_assistant/src/mcp/handler.rs @@ -265,7 +265,6 @@ impl MessageHandler { let mut context = crate::tools::core::ToolContext { project_manager: self.project_manager.as_ref(), command_executor: self.command_executor.as_ref(), - working_memory: None, plan: None, ui: None, tool_id: None, diff --git a/crates/code_assistant/src/session/instance.rs b/crates/code_assistant/src/session/instance.rs index 662903ce..2bbed095 100644 --- a/crates/code_assistant/src/session/instance.rs +++ b/crates/code_assistant/src/session/instance.rs @@ -228,10 +228,6 @@ impl SessionInstance { tool_results, }); - events.push(UiEvent::UpdateMemory { - memory: self.session.working_memory.clone(), - }); - events.push(UiEvent::UpdatePlan { plan: self.session.plan.clone(), }); diff --git a/crates/code_assistant/src/tests/format_on_save_tests.rs b/crates/code_assistant/src/tests/format_on_save_tests.rs index 1694be30..d3952d21 100644 --- a/crates/code_assistant/src/tests/format_on_save_tests.rs +++ b/crates/code_assistant/src/tests/format_on_save_tests.rs @@ -3,7 +3,7 @@ use crate::tools::core::{Tool, ToolContext}; use crate::tools::impls::edit::{EditInput, EditTool}; use crate::tools::impls::replace_in_file::{ReplaceInFileInput, ReplaceInFileTool}; use crate::tools::impls::write_file::{WriteFileInput, WriteFileTool}; -use crate::types::{LoadedResource, Project, WorkingMemory}; +use crate::types::Project; use anyhow::Result; use command_executor::CommandOutput; use fs_explorer::file_updater::{ @@ -191,11 +191,9 @@ async fn test_edit_tool_parameter_update_after_formatting() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -233,14 +231,6 @@ async fn test_edit_tool_parameter_update_after_formatting() -> Result<()> { assert_eq!(input.old_text, "const y = 2;"); // search unchanged assert_eq!(input.new_text, "const y = 42;"); // replacement reflects formatted code - // Verify that working memory contains the formatted full file content - let key = ("test-project".to_string(), PathBuf::from("test.js")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert_eq!(content, "const x = 1;const y = 42;const z = 3;"); - } else { - panic!("Expected file in working memory"); - } - Ok(()) } @@ -274,11 +264,9 @@ async fn test_write_file_with_format_on_save() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -348,11 +336,9 @@ async fn test_replace_in_file_with_format_on_save() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -396,15 +382,6 @@ async fn test_replace_in_file_with_format_on_save() -> Result<()> { assert!(input.diff.contains("version = \"0.2.0\"")); assert!(input.diff.contains("serde = \"2.0\"")); - // Verify that working memory contains the final formatted content - let key = ("test-project".to_string(), PathBuf::from("config.toml")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(content.contains("version = \"0.2.0\"")); - assert!(content.contains("serde = \"2.0\"")); - } else { - panic!("Expected file in working memory"); - } - Ok(()) } @@ -433,11 +410,9 @@ async fn test_no_format_when_pattern_doesnt_match() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -511,11 +486,9 @@ async fn test_format_on_save_multiple_patterns() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -609,11 +582,9 @@ async fn test_format_on_save_glob_patterns() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, @@ -700,11 +671,9 @@ async fn test_format_on_save_with_conflicting_matches() -> Result<()> { Box::new(explorer), )); - let mut working_memory = WorkingMemory::default(); let mut context = ToolContext { project_manager: project_manager.as_ref(), command_executor: &command_executor, - working_memory: Some(&mut working_memory), plan: None, ui: None, tool_id: None, diff --git a/crates/code_assistant/src/tests/mocks.rs b/crates/code_assistant/src/tests/mocks.rs index 37095a4e..b081109f 100644 --- a/crates/code_assistant/src/tests/mocks.rs +++ b/crates/code_assistant/src/tests/mocks.rs @@ -220,7 +220,6 @@ pub fn create_failed_command_executor_mock() -> MockCommandExecutor { pub fn create_test_tool_context<'a>( project_manager: &'a dyn crate::config::ProjectManager, command_executor: &'a dyn CommandExecutor, - working_memory: Option<&'a mut crate::types::WorkingMemory>, plan: Option<&'a mut crate::types::PlanState>, ui: Option<&'a dyn crate::ui::UserInterface>, tool_id: Option, @@ -228,7 +227,6 @@ pub fn create_test_tool_context<'a>( crate::tools::core::ToolContext { project_manager, command_executor, - working_memory, plan, ui, tool_id, @@ -987,7 +985,6 @@ impl ProjectManager for MockProjectManager { pub struct ToolTestFixture { project_manager: MockProjectManager, command_executor: MockCommandExecutor, - working_memory: Option, plan: Option, ui: Option, tool_id: Option, @@ -1000,7 +997,6 @@ impl ToolTestFixture { Self { project_manager: MockProjectManager::new(), command_executor: MockCommandExecutor::new(vec![]), - working_memory: None, plan: None, ui: None, tool_id: None, @@ -1066,7 +1062,6 @@ impl ToolTestFixture { Self { project_manager, command_executor: MockCommandExecutor::new(vec![]), - working_memory: None, plan: None, ui: None, tool_id: None, @@ -1079,7 +1074,6 @@ impl ToolTestFixture { Self { project_manager: MockProjectManager::new(), command_executor: MockCommandExecutor::new(responses), - working_memory: None, plan: None, ui: None, tool_id: None, @@ -1098,12 +1092,6 @@ impl ToolTestFixture { fixture } - /// Enable working memory for this fixture - pub fn with_working_memory(mut self) -> Self { - self.working_memory = Some(WorkingMemory::default()); - self - } - /// Enable plan state for this fixture pub fn with_plan(mut self) -> Self { self.plan = Some(PlanState::default()); @@ -1136,7 +1124,6 @@ impl ToolTestFixture { ToolContext { project_manager: &self.project_manager, command_executor: &self.command_executor, - working_memory: self.working_memory.as_mut(), plan: self.plan.as_mut(), ui: self.ui.as_ref().map(|ui| ui as &dyn UserInterface), tool_id: self.tool_id.clone(), @@ -1155,16 +1142,6 @@ impl ToolTestFixture { &self.project_manager } - /// Get a reference to the working memory for assertions - pub fn working_memory(&self) -> Option<&WorkingMemory> { - self.working_memory.as_ref() - } - - /// Get a mutable reference to the working memory for modifications - pub fn working_memory_mut(&mut self) -> Option<&mut WorkingMemory> { - self.working_memory.as_mut() - } - /// Get a reference to the plan state for assertions pub fn plan(&self) -> Option<&PlanState> { self.plan.as_ref() diff --git a/crates/code_assistant/src/tools/core/tool.rs b/crates/code_assistant/src/tools/core/tool.rs index 927ea513..8236958e 100644 --- a/crates/code_assistant/src/tools/core/tool.rs +++ b/crates/code_assistant/src/tools/core/tool.rs @@ -2,7 +2,7 @@ use super::render::Render; use super::result::ToolResult; use super::spec::ToolSpec; use crate::permissions::PermissionMediator; -use crate::types::{PlanState, WorkingMemory}; +use crate::types::PlanState; use anyhow::{anyhow, Result}; use command_executor::CommandExecutor; use serde::{de::DeserializeOwned, Deserialize, Serialize}; @@ -13,8 +13,6 @@ pub struct ToolContext<'a> { pub project_manager: &'a dyn crate::config::ProjectManager, /// Command executor for running shell commands pub command_executor: &'a dyn CommandExecutor, - /// Optional working memory (available in WorkingMemoryAgent mode) - pub working_memory: Option<&'a mut WorkingMemory>, /// Optional plan state reference for plan-related tools pub plan: Option<&'a mut PlanState>, /// Optional UI instance for streaming output diff --git a/crates/code_assistant/src/tools/impls/delete_files.rs b/crates/code_assistant/src/tools/impls/delete_files.rs index d71badfa..ab15bbb1 100644 --- a/crates/code_assistant/src/tools/impls/delete_files.rs +++ b/crates/code_assistant/src/tools/impls/delete_files.rs @@ -149,12 +149,14 @@ impl Tool for DeleteFilesTool { Ok(_) => { deleted.push(path.clone()); - // If we have a working memory reference, remove the deleted file - if let Some(working_memory) = &mut context.working_memory { - // Remove from loaded resources - working_memory - .loaded_resources - .remove(&(input.project.clone(), path.clone())); + // Emit resource event + if let Some(ui) = context.ui { + let _ = ui + .send_event(crate::ui::UiEvent::ResourceDeleted { + project: input.project.clone(), + path: path.clone(), + }) + .await; } } Err(e) => { @@ -200,26 +202,13 @@ mod tests { } #[tokio::test] - async fn test_delete_files_working_memory_update() -> Result<()> { - // Create test fixture with working memory + async fn test_delete_files_emits_resource_deleted_event() -> Result<()> { + // Create test fixture with UI let mut fixture = ToolTestFixture::with_files(vec![ ("file1.txt".to_string(), "File 1 content".to_string()), ("file2.txt".to_string(), "File 2 content".to_string()), ]) - .with_working_memory(); - - // Pre-populate working memory with files - { - let working_memory = fixture.working_memory_mut().unwrap(); - working_memory.loaded_resources.insert( - ("test-project".to_string(), PathBuf::from("file1.txt")), - crate::types::LoadedResource::File("File 1 content".to_string()), - ); - working_memory.loaded_resources.insert( - ("test-project".to_string(), PathBuf::from("file2.txt")), - crate::types::LoadedResource::File("File 2 content".to_string()), - ); - } + .with_ui(); let mut context = fixture.context(); @@ -238,15 +227,16 @@ mod tests { assert_eq!(result.deleted[0], PathBuf::from("file1.txt")); assert!(result.failed.is_empty()); - // Verify working memory updates - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 1); - assert!(!working_memory - .loaded_resources - .contains_key(&("test-project".to_string(), PathBuf::from("file1.txt")))); - assert!(working_memory - .loaded_resources - .contains_key(&("test-project".to_string(), PathBuf::from("file2.txt")))); + // Drop context to release borrow + drop(context); + + // Verify ResourceDeleted event was emitted + let events = fixture.ui().unwrap().events(); + assert!(events.iter().any(|e| matches!( + e, + crate::ui::UiEvent::ResourceDeleted { project, path } + if project == "test-project" && path == &PathBuf::from("file1.txt") + ))); Ok(()) } diff --git a/crates/code_assistant/src/tools/impls/edit.rs b/crates/code_assistant/src/tools/impls/edit.rs index bc0910e1..4ea1e1c8 100644 --- a/crates/code_assistant/src/tools/impls/edit.rs +++ b/crates/code_assistant/src/tools/impls/edit.rs @@ -1,7 +1,6 @@ use crate::tools::core::{ Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolScope, ToolSpec, }; -use crate::types::LoadedResource; use anyhow::{anyhow, Result}; use fs_explorer::{FileReplacement, FileUpdaterError}; use serde::{Deserialize, Serialize}; @@ -190,7 +189,7 @@ impl Tool for EditTool { }; match format_result { - Ok((new_content, updated_replacements)) => { + Ok((_new_content, updated_replacements)) => { // If formatting updated the replacement parameters, update our input if let Some(updated) = updated_replacements { if let Some(updated_replacement) = updated.first() { @@ -200,13 +199,14 @@ impl Tool for EditTool { } } - // If we have a working memory reference, update it with the modified file - if let Some(working_memory) = &mut context.working_memory { - // Add the file with new content to working memory - working_memory.loaded_resources.insert( - (input.project.clone(), path.clone()), - LoadedResource::File(new_content.clone()), - ); + // Emit resource event + if let Some(ui) = context.ui { + let _ = ui + .send_event(crate::ui::UiEvent::ResourceWritten { + project: input.project.clone(), + path: path.clone(), + }) + .await; } Ok(EditOutput { @@ -285,12 +285,12 @@ mod tests { #[tokio::test] async fn test_edit_basic_replacement() -> Result<()> { - // Create test fixture with working memory + // Create test fixture with UI for event capture let mut fixture = ToolTestFixture::with_files(vec![( "test.rs".to_string(), "fn original() {\n println!(\"Original\");\n}".to_string(), )]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Create input for a valid replacement @@ -309,30 +309,28 @@ mod tests { // Verify the result assert!(result.error.is_none()); - // Verify that working memory was updated - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 1); + // Drop context to release borrow + drop(context); - // Verify the content in working memory - let key = ("test-project".to_string(), PathBuf::from("test.rs")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(content.contains("fn renamed()")); - assert!(content.contains("println!(\"Updated\")")); - } else { - panic!("File not found in working memory or wrong resource type"); - } + // Verify that ResourceWritten event was emitted + let events = fixture.ui().unwrap().events(); + assert!(events.iter().any(|e| matches!( + e, + crate::ui::UiEvent::ResourceWritten { project, path } + if project == "test-project" && path == &PathBuf::from("test.rs") + ))); Ok(()) } #[tokio::test] async fn test_edit_replace_all() -> Result<()> { - // Create test fixture with working memory + // Create test fixture with UI for event capture let mut fixture = ToolTestFixture::with_files(vec![( "test.js".to_string(), "console.log('test1');\nconsole.log('test2');\nconsole.log('test3');".to_string(), )]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Create input for replace all @@ -351,18 +349,6 @@ mod tests { // Verify the result assert!(result.error.is_none()); - // Verify the content in working memory - let working_memory = fixture.working_memory().unwrap(); - let key = ("test-project".to_string(), PathBuf::from("test.js")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(content.contains("logger.debug('test1')")); - assert!(content.contains("logger.debug('test2')")); - assert!(content.contains("logger.debug('test3')")); - assert!(!content.contains("console.log")); - } else { - panic!("File not found in working memory or wrong resource type"); - } - Ok(()) } @@ -426,7 +412,7 @@ mod tests { "fn test() {\n // TODO: Remove this comment\n println!(\"Hello\");\n}" .to_string(), )]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Delete the TODO comment @@ -444,17 +430,6 @@ mod tests { // Verify the result assert!(result.error.is_none()); - // Verify the content in working memory - let working_memory = fixture.working_memory().unwrap(); - let key = ("test-project".to_string(), PathBuf::from("test.rs")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(!content.contains("TODO")); - assert!(content.contains("fn test() {")); - assert!(content.contains("println!(\"Hello\");")); - } else { - panic!("File not found in working memory or wrong resource type"); - } - Ok(()) } @@ -467,7 +442,7 @@ mod tests { "function test() {\r\n console.log('test');\r\n}".to_string(), ), // CRLF endings ]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Use LF endings in search text, should still match CRLF in file @@ -485,17 +460,6 @@ mod tests { // Verify the result assert!(result.error.is_none()); - // Verify the content in working memory - let working_memory = fixture.working_memory().unwrap(); - let key = ("test-project".to_string(), PathBuf::from("test.rs")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(content.contains("function answer()")); - assert!(content.contains("return 42;")); - assert!(!content.contains("console.log")); - } else { - panic!("File not found in working memory or wrong resource type"); - } - Ok(()) } } diff --git a/crates/code_assistant/src/tools/impls/list_files.rs b/crates/code_assistant/src/tools/impls/list_files.rs index ab879913..3178e359 100644 --- a/crates/code_assistant/src/tools/impls/list_files.rs +++ b/crates/code_assistant/src/tools/impls/list_files.rs @@ -2,7 +2,7 @@ use crate::tools::core::{ Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolScope, ToolSpec, }; use anyhow::Result; -use fs_explorer::{FileSystemEntryType, FileTreeEntry}; +use fs_explorer::FileTreeEntry; use serde::{Deserialize, Serialize}; use serde_json::json; use std::path::PathBuf; @@ -23,50 +23,6 @@ pub struct ListFilesOutput { pub failed_paths: Vec<(String, String)>, } -// Helper function to update file tree in working memory -fn update_tree_entry( - parent: &mut FileTreeEntry, - path: PathBuf, - entry: FileTreeEntry, -) -> Result<()> { - let components: Vec<_> = path.components().collect(); - if components.is_empty() { - // Replace current node with new entry - *parent = entry; - return Ok(()); - } - - // Process path components - let first = components[0].as_os_str().to_string_lossy().to_string(); - let remaining = if components.len() > 1 { - let mut new_path = PathBuf::new(); - for component in &components[1..] { - new_path.push(component); - } - Some(new_path) - } else { - None - }; - - // Insert or update the child node - if let Some(remaining_path) = remaining { - let child = parent - .children - .entry(first.clone()) - .or_insert_with(|| FileTreeEntry { - name: first.clone(), - entry_type: FileSystemEntryType::Directory, - children: std::collections::HashMap::new(), - is_expanded: true, - }); - update_tree_entry(child, remaining_path, entry)?; - } else { - parent.children.insert(first, entry); - } - - Ok(()) -} - // Render implementation for output formatting impl Render for ListFilesOutput { fn status(&self) -> String { @@ -217,45 +173,15 @@ impl Tool for ListFilesTool { } } - // If we have a working memory reference, update it with the expanded paths - if let Some(working_memory) = &mut context.working_memory { - // Create file tree for this project if it doesn't exist yet - let file_tree = working_memory - .file_trees - .entry(input.project.clone()) - .or_insert_with(|| FileTreeEntry { - name: input.project.clone(), - entry_type: FileSystemEntryType::Directory, - children: std::collections::HashMap::new(), - is_expanded: true, - }); - - // Update file tree with each entry - for (path, entry) in &expanded_paths { - if let Err(e) = update_tree_entry(file_tree, path.clone(), entry.clone()) { - eprintln!("Error updating tree entry: {e}"); - // Continue with other entries even if one fails - } - } - - // Store expanded directories for this project - let project_paths = working_memory - .expanded_directories - .entry(input.project.clone()) - .or_insert_with(Vec::new); - - // Add all paths that were listed for this project + // Emit directory listed events for each successful path + if let Some(ui) = context.ui { for (path, _) in &expanded_paths { - if !project_paths.contains(path) { - project_paths.push(path.clone()); - } - } - - // Make sure project is in available_projects list - if !working_memory.available_projects.contains(&input.project) { - working_memory - .available_projects - .push(input.project.clone()); + let _ = ui + .send_event(crate::ui::UiEvent::DirectoryListed { + project: input.project.clone(), + path: path.clone(), + }) + .await; } } diff --git a/crates/code_assistant/src/tools/impls/perplexity_ask.rs b/crates/code_assistant/src/tools/impls/perplexity_ask.rs index 6414d5a9..94f68810 100644 --- a/crates/code_assistant/src/tools/impls/perplexity_ask.rs +++ b/crates/code_assistant/src/tools/impls/perplexity_ask.rs @@ -4,7 +4,6 @@ use crate::tools::core::{ use anyhow::Result; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::path::PathBuf; use web::{PerplexityCitation, PerplexityClient, PerplexityMessage}; // Input type for the perplexity_ask tool @@ -122,7 +121,7 @@ impl Tool for PerplexityAskTool { async fn execute<'a>( &self, - context: &mut ToolContext<'a>, + _context: &mut ToolContext<'a>, input: &mut Self::Input, ) -> Result { // Check if the API key exists @@ -142,48 +141,12 @@ impl Tool for PerplexityAskTool { // Call Perplexity API match client.ask(&input.messages, None).await { - Ok(response) => { - // Update working memory if available - if let Some(working_memory) = &mut context.working_memory { - // Store the result as a resource with a synthetic path - let path = PathBuf::from(format!( - "perplexity-ask-{}", - percent_encoding::utf8_percent_encode( - &query, - percent_encoding::NON_ALPHANUMERIC - ) - )); - - // Format the answer with citations - let mut full_answer = response.content.clone(); - if !response.citations.is_empty() { - full_answer.push_str("\n\nCitations:\n"); - for (i, citation) in response.citations.iter().enumerate() { - full_answer.push_str(&format!( - "[{}] {}: {}\n", - i + 1, - citation.text, - citation.url - )); - } - } - - // Store as a file resource - let project = "perplexity".to_string(); - working_memory.add_resource( - project, - path, - crate::types::LoadedResource::File(full_answer), - ); - } - - Ok(PerplexityAskOutput { - query, - answer: response.content, - citations: response.citations, - error: None, - }) - } + Ok(response) => Ok(PerplexityAskOutput { + query, + answer: response.content, + citations: response.citations, + error: None, + }), Err(e) => Ok(PerplexityAskOutput { query, answer: String::new(), diff --git a/crates/code_assistant/src/tools/impls/read_files.rs b/crates/code_assistant/src/tools/impls/read_files.rs index 612850b5..046c8c6f 100644 --- a/crates/code_assistant/src/tools/impls/read_files.rs +++ b/crates/code_assistant/src/tools/impls/read_files.rs @@ -2,7 +2,6 @@ use crate::tools::core::{ Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolScope, ToolSpec, }; use crate::tools::parse::PathWithLineRange; -use crate::types::LoadedResource; use anyhow::{anyhow, Result}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -209,10 +208,9 @@ impl Tool for ReadFilesTool { } } - // If we have a working memory reference, update it with the loaded files - if let Some(working_memory) = &mut context.working_memory { - // Store successfully loaded files in working memory - for (path, content) in &loaded_files { + // Emit resource events for loaded files + if let Some(ui) = context.ui { + for path in loaded_files.keys() { // Get the base path without any line range information let base_path = if let Ok(parsed) = PathWithLineRange::parse(path.to_str().unwrap_or("")) { @@ -220,10 +218,12 @@ impl Tool for ReadFilesTool { } else { path.clone() }; - working_memory.loaded_resources.insert( - (input.project.clone(), base_path.clone()), - LoadedResource::File(content.clone()), - ); + let _ = ui + .send_event(crate::ui::UiEvent::ResourceLoaded { + project: input.project.clone(), + path: base_path, + }) + .await; } } @@ -266,7 +266,9 @@ mod tests { } #[tokio::test] - async fn test_read_files_updates_memory() -> Result<()> { + async fn test_read_files_emits_resource_loaded_events() -> Result<()> { + use crate::ui::UiEvent; + // Create a tool registry let registry = ToolRegistry::global(); @@ -275,7 +277,7 @@ mod tests { .get("read_files") .expect("read_files tool should be registered"); - // Create test fixture with files and working memory + // Create test fixture with files and UI for event capture let mut fixture = ToolTestFixture::with_files(vec![ ( "test.txt".to_string(), @@ -283,7 +285,7 @@ mod tests { ), ("test2.txt".to_string(), "Another file content".to_string()), ]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Parameters for read_files @@ -302,17 +304,30 @@ mod tests { // Check the output assert!(output.contains("Successfully loaded")); - // Verify that the files were added to working memory - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 2); - - // Check that both files are in the working memory - assert!(working_memory - .loaded_resources - .contains_key(&("test-project".to_string(), "test.txt".into()))); - assert!(working_memory - .loaded_resources - .contains_key(&("test-project".to_string(), "test2.txt".into()))); + // Drop context to release borrow before checking events + drop(context); + + // Verify ResourceLoaded events were emitted + let events = fixture.ui().unwrap().events(); + let resource_events: Vec<_> = events + .iter() + .filter(|e| matches!(e, UiEvent::ResourceLoaded { .. })) + .collect(); + + assert_eq!(resource_events.len(), 2, "Expected 2 ResourceLoaded events"); + + // Check that both files have events + let has_test_txt = events.iter().any(|e| { + matches!(e, UiEvent::ResourceLoaded { project, path } + if project == "test-project" && path == &PathBuf::from("test.txt")) + }); + let has_test2_txt = events.iter().any(|e| { + matches!(e, UiEvent::ResourceLoaded { project, path } + if project == "test-project" && path == &PathBuf::from("test2.txt")) + }); + + assert!(has_test_txt, "Expected ResourceLoaded event for test.txt"); + assert!(has_test2_txt, "Expected ResourceLoaded event for test2.txt"); Ok(()) } diff --git a/crates/code_assistant/src/tools/impls/replace_in_file.rs b/crates/code_assistant/src/tools/impls/replace_in_file.rs index 4582fbac..0741174b 100644 --- a/crates/code_assistant/src/tools/impls/replace_in_file.rs +++ b/crates/code_assistant/src/tools/impls/replace_in_file.rs @@ -2,7 +2,6 @@ use crate::tools::core::{ Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolScope, ToolSpec, }; use crate::tools::parse::parse_search_replace_blocks; -use crate::types::LoadedResource; use anyhow::{anyhow, Result}; use fs_explorer::{FileReplacement, FileUpdaterError}; use serde::{Deserialize, Serialize}; @@ -220,17 +219,20 @@ impl Tool for ReplaceInFileTool { }; match result { - Ok((new_content, updated_replacements)) => { + Ok((_new_content, updated_replacements)) => { // If we have updated replacements (after formatting), update the diff text if let Some(updated) = updated_replacements { input.diff = render_diff_from_replacements(&updated); } - if let Some(working_memory) = &mut context.working_memory { - working_memory.loaded_resources.insert( - (input.project.clone(), path.clone()), - LoadedResource::File(new_content.clone()), - ); + // Emit resource event + if let Some(ui) = context.ui { + let _ = ui + .send_event(crate::ui::UiEvent::ResourceWritten { + project: input.project.clone(), + path: path.clone(), + }) + .await; } Ok(ReplaceInFileOutput { @@ -305,13 +307,14 @@ mod tests { } #[tokio::test] - async fn test_replace_in_file_working_memory_update() -> Result<()> { - // Create test fixture with working memory + + async fn test_replace_in_file_emits_resource_event() -> Result<()> { + // Create test fixture with UI for event capture let mut fixture = ToolTestFixture::with_files(vec![( "test.rs".to_string(), "fn original() {\n println!(\"Original\");\n}".to_string(), )]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Create input for a valid replacement @@ -328,18 +331,16 @@ mod tests { // Verify the result assert!(result.error.is_none()); - // Verify that working memory was updated - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 1); + // Drop context to release borrow + drop(context); - // Verify the content in working memory - let key = ("test-project".to_string(), PathBuf::from("test.rs")); - if let Some(LoadedResource::File(content)) = working_memory.loaded_resources.get(&key) { - assert!(content.contains("fn renamed()")); - assert!(content.contains("println!(\"Updated\")")); - } else { - panic!("File not found in working memory or wrong resource type"); - } + // Verify that ResourceWritten event was emitted + let events = fixture.ui().unwrap().events(); + assert!(events.iter().any(|e| matches!( + e, + crate::ui::UiEvent::ResourceWritten { project, path } + if project == "test-project" && path == &PathBuf::from("test.rs") + ))); Ok(()) } diff --git a/crates/code_assistant/src/tools/impls/web_fetch.rs b/crates/code_assistant/src/tools/impls/web_fetch.rs index a7b3bb36..8b6c5d12 100644 --- a/crates/code_assistant/src/tools/impls/web_fetch.rs +++ b/crates/code_assistant/src/tools/impls/web_fetch.rs @@ -103,7 +103,7 @@ impl Tool for WebFetchTool { async fn execute<'a>( &self, - context: &mut ToolContext<'a>, + _context: &mut ToolContext<'a>, input: &mut Self::Input, ) -> Result { // Create new client for each request @@ -119,26 +119,7 @@ impl Tool for WebFetchTool { // Fetch the page match client.fetch(&input.url).await { - Ok(page) => { - // Update working memory if available - if let Some(working_memory) = &mut context.working_memory { - // Use the URL as path (normalized) - let path = - std::path::PathBuf::from(page.url.replace([':', '/', '?', '#'], "_")); - - // Use "web" as the project name for web resources - let project = "web".to_string(); - - // Store in working memory - working_memory.add_resource( - project, - path, - crate::types::LoadedResource::WebPage(page.clone()), - ); - } - - Ok(WebFetchOutput { page, error: None }) - } + Ok(page) => Ok(WebFetchOutput { page, error: None }), Err(e) => Ok(WebFetchOutput { page: WebPage::default(), error: Some(e.to_string()), diff --git a/crates/code_assistant/src/tools/impls/write_file.rs b/crates/code_assistant/src/tools/impls/write_file.rs index 9cecb0dc..9782934d 100644 --- a/crates/code_assistant/src/tools/impls/write_file.rs +++ b/crates/code_assistant/src/tools/impls/write_file.rs @@ -1,7 +1,6 @@ use crate::tools::core::{ Render, ResourcesTracker, Tool, ToolContext, ToolResult, ToolScope, ToolSpec, }; -use crate::types::LoadedResource; use anyhow::Result; use command_executor::SandboxCommandRequest; use serde::{Deserialize, Serialize}; @@ -185,12 +184,14 @@ impl Tool for WriteFileTool { } } - // Update working memory if present - if let Some(working_memory) = &mut context.working_memory { - working_memory.loaded_resources.insert( - (input.project.clone(), path.clone()), - LoadedResource::File(full_content.clone()), - ); + // Emit resource event + if let Some(ui) = context.ui { + let _ = ui + .send_event(crate::ui::UiEvent::ResourceWritten { + project: input.project.clone(), + path: path.clone(), + }) + .await; } Ok(WriteFileOutput { @@ -246,7 +247,8 @@ mod tests { let write_file_tool = WriteFileTool; // Create test fixture with working memory - let mut fixture = ToolTestFixture::new().with_working_memory(); + + let mut fixture = ToolTestFixture::new().with_ui(); let mut context = fixture.context(); // Parameters for write_file @@ -263,37 +265,30 @@ mod tests { // Check the result assert!(result.error.is_none()); - // Verify that the file was added to working memory - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 1); - - // Check that the file is in the working memory - let resource_key = ("test".to_string(), PathBuf::from("test.txt")); - assert!(working_memory.loaded_resources.contains_key(&resource_key)); + // Drop context to release borrow + drop(context); - // Check that the content matches - if let Some(LoadedResource::File(content)) = - working_memory.loaded_resources.get(&resource_key) - { - assert_eq!(content, "Test content"); - } else { - panic!("Expected file resource in working memory"); - } + // Verify that ResourceWritten event was emitted + let events = fixture.ui().unwrap().events(); + assert!(events.iter().any(|e| matches!( + e, + crate::ui::UiEvent::ResourceWritten { project, path } + if project == "test" && path == &PathBuf::from("test.txt") + ))); Ok(()) } #[tokio::test] - async fn test_write_file_append_has_memory_update() -> Result<()> { - // Create a tool registry (not needed for this test but kept for consistency) + async fn test_write_file_append_emits_event() -> Result<()> { let write_file_tool = WriteFileTool; - // Create test fixture with existing file and working memory + // Create test fixture with existing file and UI let mut fixture = ToolTestFixture::with_files(vec![( "test.txt".to_string(), "Initial content".to_string(), )]) - .with_working_memory(); + .with_ui(); let mut context = fixture.context(); // Parameters for write_file with append=true @@ -310,23 +305,16 @@ mod tests { // Check the result assert!(result.error.is_none()); - // Verify that the file WAS added to working memory (we fixed the behavior) - let working_memory = fixture.working_memory().unwrap(); - assert_eq!(working_memory.loaded_resources.len(), 1); - - // Check that the file is in the working memory - let resource_key = ("test-project".to_string(), PathBuf::from("test.txt")); - assert!(working_memory.loaded_resources.contains_key(&resource_key)); + // Drop context to release borrow + drop(context); - // Check that the content is the combined content (initial + appended) - if let Some(LoadedResource::File(content)) = - working_memory.loaded_resources.get(&resource_key) - { - assert!(content.contains("Initial content")); - assert!(content.contains("Test content")); - } else { - panic!("Expected file resource in working memory"); - } + // Verify that ResourceWritten event was emitted + let events = fixture.ui().unwrap().events(); + assert!(events.iter().any(|e| matches!( + e, + crate::ui::UiEvent::ResourceWritten { project, path } + if project == "test-project" && path == &PathBuf::from("test.txt") + ))); Ok(()) } diff --git a/crates/code_assistant/src/types.rs b/crates/code_assistant/src/types.rs index cb890e8f..325e9c50 100644 --- a/crates/code_assistant/src/types.rs +++ b/crates/code_assistant/src/types.rs @@ -178,13 +178,6 @@ impl std::fmt::Display for LoadedResource { } } -impl WorkingMemory { - /// Add a new resource to working memory - pub fn add_resource(&mut self, project: String, path: PathBuf, resource: LoadedResource) { - self.loaded_resources.insert((project, path), resource); - } -} - /// Tool description for LLM #[derive(Debug, thiserror::Error)] pub enum ToolError { diff --git a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs index 429e6eba..d5ba0525 100644 --- a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs +++ b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs @@ -7,7 +7,8 @@ use gpui::{ Styled, Subscription, Window, }; use gpui_component::scroll::ScrollbarAxis; -use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, StyledExt}; + +use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, Sizable, Size, StyledExt}; use std::time::SystemTime; use tracing::debug; @@ -464,7 +465,12 @@ impl ChatSidebar { cx: &mut Context, ) { debug!("New chat button clicked"); - // Emit event to parent to create a new chat session + self.request_new_session(cx); + } + + /// Request creation of a new chat session + pub fn request_new_session(&mut self, cx: &mut Context) { + debug!("Requesting new chat session"); cx.emit(ChatSidebarEvent::NewSessionRequested { name: None }); } @@ -501,7 +507,7 @@ impl Focusable for ChatSidebar { impl Render for ChatSidebar { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { if self.is_collapsed { - // Collapsed view - narrow bar with toggle button + // Collapsed view - narrow bar with new chat button div() .id("collapsed-chat-sidebar") .flex_none() @@ -517,17 +523,20 @@ impl Render for ChatSidebar { .py_2() .child( div() - .size(px(24.)) - .rounded_full() + .size(px(28.)) + .rounded_sm() .flex() .items_center() .justify_center() - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::MESSAGE_BUBBLES), - 16.0, - cx.theme().muted_foreground, - "💬", - )), + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from("icons/plus.svg")) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_new_chat_click)), ) } else { // Full sidebar view diff --git a/crates/code_assistant/src/ui/gpui/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index e43d05db..09fcc219 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -1,12 +1,9 @@ use gpui::{div, px, svg, App, AssetSource, IntoElement, ParentElement, SharedString, Styled}; use serde::Deserialize; use std::collections::{HashMap, HashSet}; -use std::path::Path; use std::sync::{Arc, Mutex, OnceLock}; use tracing::{debug, trace, warn}; -use crate::ui::gpui::path_util::PathExt; - /// Represents icon information for different file types #[derive(Deserialize, Debug)] struct TypeConfig { @@ -25,31 +22,14 @@ struct FileTypesConfig { pub struct FileIcons { /// The loaded configuration from file_types.json config: FileTypesConfig, - /// Fallback emoji icons for when SVGs aren't available - fallback_stems: HashMap, - fallback_suffixes: HashMap, /// Set of already logged missing icon paths to avoid duplicate warnings logged_missing_icons: Mutex>, } -// Public icon type constants that already exist in file_types.json -pub const DIRECTORY_COLLAPSED: &str = "collapsed_folder"; // folder.svg -pub const DIRECTORY_EXPANDED: &str = "expanded_folder"; // folder_open.svg - // pub const CHEVRON_LEFT: &str = "chevron_left"; // chevron_left.svg - // pub const CHEVRON_RIGHT: &str = "chevron_right"; // chevron_right.svg +// Public icon type constants pub const CHEVRON_DOWN: &str = "chevron_down"; // chevron_down.svg pub const CHEVRON_UP: &str = "chevron_up"; // chevron_up.svg pub const WORKING_MEMORY: &str = "brain"; // brain.svg -pub const LIBRARY: &str = "library"; // library.svg -pub const FILE_TREE: &str = "file_tree"; // file_tree.svg -pub const MAGNIFYING_GLASS: &str = "magnifying_glass"; // magnifying_glass.svg -pub const HTML: &str = "template"; // html.svg -pub const DEFAULT: &str = "default"; // file.svg - -pub const PANEL_RIGHT_CLOSE: &str = "panel_right_close"; // panel_right_close.svg -pub const PANEL_RIGHT_OPEN: &str = "panel_right_open"; // panel_right_open.svg -pub const THEME_DARK: &str = "theme_dark"; // theme_dark.svg -pub const THEME_LIGHT: &str = "theme_light"; // theme_light.svg pub const SEND: &str = "send"; // send.svg pub const STOP: &str = "stop"; // circle_stop.svg @@ -57,7 +37,6 @@ pub const MESSAGE_BUBBLES: &str = "message_bubbles"; // message_bubbles.svg pub const PLUS: &str = "plus"; // plus.svg // Tool-specific icon mappings to actual SVG files -// These are direct constants defining the paths to SVG icons or existing types pub const TOOL_READ_FILES: &str = "search_code"; // search_code.svg pub const TOOL_LIST_FILES: &str = "reveal"; // reveal.svg pub const TOOL_EXECUTE_COMMAND: &str = "terminal"; // terminal.svg @@ -83,32 +62,8 @@ impl FileIcons { // Load the configuration from the JSON file let config = Self::load_config(&assets); - // Initialize fallback emoji mappings - let mut fallback_stems = HashMap::new(); - let mut fallback_suffixes = HashMap::new(); - - // Initialize with common file types as fallbacks - fallback_suffixes.insert("rs".to_string(), "🦀".to_string()); - fallback_suffixes.insert("js".to_string(), "📜".to_string()); - fallback_suffixes.insert("jsx".to_string(), "⚛️".to_string()); - fallback_suffixes.insert("ts".to_string(), "📘".to_string()); - fallback_suffixes.insert("tsx".to_string(), "⚛️".to_string()); - fallback_suffixes.insert("py".to_string(), "🐍".to_string()); - fallback_suffixes.insert("html".to_string(), "🌐".to_string()); - fallback_suffixes.insert("css".to_string(), "🎨".to_string()); - fallback_suffixes.insert("json".to_string(), "📋".to_string()); - fallback_suffixes.insert("md".to_string(), "📝".to_string()); - - // Special file stems - fallback_stems.insert("Cargo.toml".to_string(), "📦".to_string()); - fallback_stems.insert("package.json".to_string(), "📦".to_string()); - fallback_stems.insert("Dockerfile".to_string(), "🐳".to_string()); - fallback_stems.insert("README.md".to_string(), "📚".to_string()); - Self { config, - fallback_stems, - fallback_suffixes, logged_missing_icons: Mutex::new(HashSet::new()), } } @@ -154,69 +109,6 @@ impl FileIcons { } } - /// Get the appropriate icon for a file path - pub fn get_icon(&self, path: &Path) -> Option { - // Extract the stem or suffix from the path - let suffix = match path.icon_stem_or_suffix() { - Some(s) => s, - None => { - let path_str = path.to_string_lossy().to_string(); - self.log_missing_icon( - &format!("[FileIcons]: No suffix found for path: {path:?}"), - &format!("no_suffix:{path_str}"), - ); - return self.get_type_icon(DEFAULT); - } - }; - - // First check if we have a match in the stems mapping - if let Some(type_str) = self.config.stems.get(suffix) { - trace!( - "[FileIcons]: Found stem match: '{}' -> '{}'", - suffix, - type_str - ); - return self.get_type_icon(type_str); - } - - // Then check if we have a match in the suffixes mapping - if let Some(type_str) = self.config.suffixes.get(suffix) { - trace!( - "[FileIcons]: Found suffix match: '{}' -> '{}'", - suffix, - type_str - ); - return self.get_type_icon(type_str); - } - - // Try fallback stems for specific filenames - if let Some(filename) = path.file_name() { - if let Some(filename_str) = filename.to_str() { - if let Some(icon) = self.fallback_stems.get(filename_str) { - debug!( - "[FileIcons]: Using fallback stem icon for: '{}'", - filename_str - ); - return Some(SharedString::from(icon.clone())); - } - } - } - - // Try fallback suffixes for extensions - if let Some(fallback) = self.fallback_suffixes.get(suffix) { - debug!("[FileIcons]: Using fallback suffix icon for: '{}'", suffix); - return Some(SharedString::from(fallback.clone())); - } - - // Default icon - let path_str = path.to_string_lossy().to_string(); - self.log_missing_icon( - &format!("[FileIcons]: Using default icon for: {path:?}"), - &format!("default_icon:{path_str}"), - ); - self.get_type_icon(DEFAULT) - } - /// Get icon based on type name - this is the core method that all icon lookups use pub fn get_type_icon(&self, typ: &str) -> Option { // First check if the type exists in the config diff --git a/crates/code_assistant/src/ui/gpui/memory.rs b/crates/code_assistant/src/ui/gpui/memory.rs deleted file mode 100644 index d4747ee5..00000000 --- a/crates/code_assistant/src/ui/gpui/memory.rs +++ /dev/null @@ -1,423 +0,0 @@ -use std::path::Path; -use std::sync::{Arc, Mutex}; - -use gpui::{div, prelude::*, px, App, Axis, Context, Entity, FocusHandle, Focusable, Window}; -use gpui_component::{ActiveTheme, StyledExt}; - -use crate::types::{LoadedResource, WorkingMemory}; -use crate::ui::gpui::file_icons; -use fs_explorer::{FileSystemEntryType, FileTreeEntry}; - -// Entity for displaying loaded resources -pub struct LoadedResourcesView { - memory: Arc>>, - focus_handle: FocusHandle, -} - -impl LoadedResourcesView { - pub fn new(memory: Arc>>, cx: &mut Context) -> Self { - Self { - memory, - focus_handle: cx.focus_handle(), - } - } -} - -impl Focusable for LoadedResourcesView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for LoadedResourcesView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let memory_lock = self.memory.lock().unwrap(); - - // Resources header (shared between both branches) - let header = div() - .id("resources-header") - .flex_none() - .text_sm() - .w_full() - .px_2() - .bg(cx.theme().popover) - .flex() - .items_center() - .justify_between() - .text_color(cx.theme().foreground) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::LIBRARY), - 16.0, - cx.theme().muted_foreground, - "📚", - )) - .child("Loaded Resources"), - ); - - // Container - let mut container = div() - .id("resources-section") - .flex_none() - .bg(cx.theme().sidebar) - .border_b_1() - .border_color(cx.theme().sidebar_border) - .flex() - .flex_col(); - - if let Some(memory) = &*memory_lock { - // Add resources count to header - let header_with_count = header.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("({})", memory.loaded_resources.len())), - ); - - // Resources content with scrollbar using .scrollable() - let resources_content = div() - .id("resources-content") - .scrollable(Axis::Vertical) - .flex() - .flex_col() - .p_1() - .gap_1() - .children( - memory - .loaded_resources - .iter() - .map(|((project, path), resource)| { - // Get appropriate icon for resource type - let icon = match resource { - LoadedResource::File(_) => file_icons::get().get_icon(path), - LoadedResource::WebSearch { .. } => { - file_icons::get().get_type_icon(file_icons::MAGNIFYING_GLASS) - } - LoadedResource::WebPage(_) => { - file_icons::get().get_type_icon(file_icons::HTML) - } - }; - - div() - .px_2() - .w_full() - .flex() - .items_center() - .justify_between() - .gap_2() - .child(file_icons::render_icon_container( - &icon, - 14.0, - cx.theme().muted_foreground, - "📄", - )) - .child( - div() - .text_color(cx.theme().foreground) - .text_xs() - .truncate() - .flex_grow() - .child(format!("{}/{}", project, path.to_string_lossy())), - ) - }), - ); - - // Update container with the resources content - container = container.child(header_with_count).child( - div() - .id("resources-list-container") - .max_h(px(300.)) - .flex_grow() - .child(resources_content), - ); - } else { - // Add empty message with header - container = container.child(header).child( - div() - .p_2() - .text_center() - .text_color(cx.theme().muted_foreground) - .child("No resources available"), - ); - } - - container - } -} - -// Entity for displaying file tree -pub struct FileTreeView { - memory: Arc>>, - focus_handle: FocusHandle, -} - -impl FileTreeView { - pub fn new(memory: Arc>>, cx: &mut Context) -> Self { - Self { - memory, - focus_handle: cx.focus_handle(), - } - } - - // Render a single file tree entry item - fn render_entry_item( - &self, - entry: &FileTreeEntry, - indent_level: usize, - cx: &Context, - ) -> gpui::Div { - // Get appropriate icon based on type and name - let icon = match entry.entry_type { - FileSystemEntryType::Directory => { - // Get folder icon based on expanded state - let icon_type = if entry.is_expanded { - file_icons::DIRECTORY_EXPANDED - } else { - file_icons::DIRECTORY_COLLAPSED - }; - file_icons::get().get_type_icon(icon_type) - } - FileSystemEntryType::File => { - // Get file icon based on file extension - let path = Path::new(&entry.name); - file_icons::get().get_icon(path) - } - }; - - let icon_color = cx.theme().muted_foreground; - - // Create the single item row - div() - .py(px(2.)) - .pl(px(indent_level as f32 * 16.0)) // Use 16px indentation per level - .flex() - .items_center() - .gap_2() - .w_full() // Ensure entry takes full width to prevent wrapping - .flex_none() // Prevent item from growing or shrinking - .child(file_icons::render_icon_container( - &icon, - 16.0, - icon_color, - match entry.entry_type { - FileSystemEntryType::Directory => "📁", - FileSystemEntryType::File => "📄", - }, - )) - .child( - div() - .text_xs() - .font_weight(gpui::FontWeight(400.)) - .text_color(cx.theme().foreground) - .child(entry.name.clone()), - ) - } - - // Generate a flat list of all file tree entries with proper indentation - fn generate_file_tree( - &self, - entry: &FileTreeEntry, - indent_level: usize, - cx: &Context, - ) -> Vec { - let mut result = Vec::new(); - - // Add current entry - result.push(self.render_entry_item(entry, indent_level, cx)); - - // Add children if expanded - if entry.is_expanded && !entry.children.is_empty() { - // Sort children: directories first, then files, both alphabetically - let mut children: Vec<&FileTreeEntry> = entry.children.values().collect(); - children.sort_by_key(|entry| { - ( - // First sort criterion: directories before files - matches!(entry.entry_type, FileSystemEntryType::File), - // Second sort criterion: alphabetical by name (case insensitive) - entry.name.to_lowercase(), - ) - }); - - // Process each child - for child in children { - // Recursively add this child and its children - let child_items = self.generate_file_tree(child, indent_level + 1, cx); - result.extend(child_items); - } - } - - result - } -} - -impl Focusable for FileTreeView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for FileTreeView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let memory_lock = self.memory.lock().unwrap(); - - // File tree header (shared between both branches) - let header = div() - .id("file-tree-header") - .flex_none() - .text_sm() - .w_full() - .px_2() - .bg(cx.theme().popover) - .flex() - .items_center() - .justify_between() - .text_color(cx.theme().foreground) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::FILE_TREE), - 16.0, - cx.theme().muted_foreground, - "🌲", - )) - .child("File Tree"), - ); - - // Container for file tree section - let mut container = div() - .id("file-tree-section") - .flex_1() // Take remaining space in parent container - .min_h(px(100.)) // Minimum height to ensure scrolling works - .flex() - .flex_col(); - - if let Some(memory) = &*memory_lock { - // File tree content - generate a flat list of items - if !memory.file_trees.is_empty() { - let mut all_entries = Vec::new(); - for root_entry in memory.file_trees.values() { - // Generate flat list of all entries for this project - // The root entry is already the project name - let entries = self.generate_file_tree(root_entry, 0, cx); - all_entries.extend(entries); - } - - let file_tree_content = div().flex().flex_col().w_full().children(all_entries); - - // Add the file tree container with scrollable content - container = container.child(header).child( - div() - .id("file-tree-container") - .flex_1() // Take remaining space in the parent container - .min_h(px(100.)) // Minimum height to ensure scrolling works - .child( - div() - .id("file-tree") - .size_full() - .scrollable(Axis::Vertical) - .p_1() - .child(file_tree_content), - ), - ); - } else { - // No file trees - container = container.child(header).child( - div() - .p_2() - .text_color(cx.theme().muted_foreground) - .text_center() - .child("No file trees available"), - ); - } - } else { - // No memory data - container = container.child(header).child( - div() - .p_2() - .text_center() - .text_color(cx.theme().muted_foreground) - .child("No file tree available"), - ); - } - - container - } -} - -// Memory sidebar component -pub struct MemoryView { - focus_handle: FocusHandle, - resources_view: Entity, - file_tree_view: Entity, -} - -impl MemoryView { - pub fn new(memory: Arc>>, cx: &mut Context) -> Self { - // Create sub-entities with the same memory - let resources_view = cx.new(|cx| LoadedResourcesView::new(memory.clone(), cx)); - let file_tree_view = cx.new(|cx| FileTreeView::new(memory.clone(), cx)); - - Self { - focus_handle: cx.focus_handle(), - resources_view, - file_tree_view, - } - } -} - -impl Focusable for MemoryView { - fn focus_handle(&self, _: &App) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl Render for MemoryView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let title_view = div() - .id("title-view") - .flex_none() - .h(px(36.)) - .w_full() - .flex() - .items_center() - .px_2() - .justify_between() - .bg(cx.theme().popover) - .text_color(cx.theme().foreground) - .child( - div() - .flex() - .items_center() - .gap_2() - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::WORKING_MEMORY), - 16.0, - cx.theme().muted_foreground, - "🧠", - )) - .child("Working Memory"), - ); - - // Build main container with the title and child entities - div() - .id("memory-sidebar") - .track_focus(&self.focus_handle(cx)) - .flex_none() - .w(px(260.)) - .h_full() - .bg(cx.theme().sidebar) - .overflow_hidden() // Prevent content from overflowing - .flex() - .flex_col() - .child(title_view) - .child(self.resources_view.clone()) - .child(self.file_tree_view.clone()) - } -} diff --git a/crates/code_assistant/src/ui/gpui/mod.rs b/crates/code_assistant/src/ui/gpui/mod.rs index 735f47d2..3d527ac0 100644 --- a/crates/code_assistant/src/ui/gpui/mod.rs +++ b/crates/code_assistant/src/ui/gpui/mod.rs @@ -9,11 +9,9 @@ pub mod elements; pub mod file_icons; pub mod image; pub mod input_area; -mod memory; mod messages; pub mod model_selector; pub mod parameter_renderers; -mod path_util; mod plan_banner; mod root; pub mod sandbox_selector; @@ -21,7 +19,7 @@ pub mod simple_renderers; pub mod theme; use crate::persistence::{ChatMetadata, DraftStorage}; -use crate::types::{PlanState, WorkingMemory}; +use crate::types::PlanState; use crate::ui::gpui::{ content_renderer::ContentRenderer, diff_renderer::DiffParameterRenderer, @@ -39,7 +37,6 @@ use gpui::{ SharedString, }; use gpui_component::Root; -pub use memory::MemoryView; pub use messages::MessagesView; pub use root::RootView; use sandbox::SandboxPolicy; @@ -65,7 +62,6 @@ pub use crate::ui::backend::{BackendEvent, BackendResponse}; #[derive(Clone)] pub struct Gpui { message_queue: Arc>>>, - working_memory: Arc>>, plan_state: Arc>>, event_sender: Arc>>, event_receiver: Arc>>, @@ -212,7 +208,6 @@ impl Gpui { pub fn new() -> Self { let message_queue = Arc::new(Mutex::new(Vec::new())); - let working_memory = Arc::new(Mutex::new(None)); let plan_state = Arc::new(Mutex::new(None)); let event_task = Arc::new(Mutex::new(None::>)); let session_event_task = Arc::new(Mutex::new(None::>)); @@ -267,7 +262,6 @@ impl Gpui { Self { message_queue, - working_memory, plan_state, event_sender, event_receiver, @@ -303,7 +297,6 @@ impl Gpui { // Run the application pub fn run_app(&self) { let message_queue = self.message_queue.clone(); - let working_memory = self.working_memory.clone(); let gpui_clone = self.clone(); // Initialize app with assets @@ -401,12 +394,9 @@ impl Gpui { *task_guard = Some(chat_response_task); } - // Create memory view with our shared working memory - let memory_view = cx.new(|cx| MemoryView::new(working_memory.clone(), cx)); - - // Create window with larger size to accommodate chat sidebar, messages, and memory view + // Create window with larger size to accommodate chat sidebar and messages let bounds = - gpui::Bounds::centered(None, gpui::size(gpui::px(1400.0), gpui::px(700.0)), cx); + gpui::Bounds::centered(None, gpui::size(gpui::px(1100.0), gpui::px(700.0)), cx); // Open window with titlebar let window = cx .open_window( @@ -439,13 +429,7 @@ impl Gpui { // Create RootView let root_view = cx.new(|cx| { - RootView::new( - memory_view.clone(), - messages_view, - chat_sidebar.clone(), - window, - cx, - ) + RootView::new(messages_view, chat_sidebar.clone(), window, cx) }); // Wrap in Root component @@ -579,12 +563,7 @@ impl Gpui { message_container.end_tool_use(&id, cx); }); } - UiEvent::UpdateMemory { memory } => { - if let Ok(mut memory_guard) = self.working_memory.lock() { - *memory_guard = Some(memory); - } - cx.refresh().expect("Failed to refresh windows"); - } + UiEvent::UpdatePlan { plan } => { if let Ok(mut plan_guard) = self.plan_state.lock() { *plan_guard = Some(plan); @@ -992,6 +971,36 @@ impl Gpui { *self.current_sandbox_policy.lock().unwrap() = Some(policy.clone()); cx.refresh().expect("Failed to refresh windows"); } + + // Resource events - logged for now, can be extended for features like "follow mode" + UiEvent::ResourceLoaded { project, path } => { + trace!( + "UI: ResourceLoaded event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::ResourceWritten { project, path } => { + trace!( + "UI: ResourceWritten event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::DirectoryListed { project, path } => { + trace!( + "UI: DirectoryListed event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::ResourceDeleted { project, path } => { + trace!( + "UI: ResourceDeleted event - project: {}, path: {}", + project, + path.display() + ); + } } } diff --git a/crates/code_assistant/src/ui/gpui/path_util.rs b/crates/code_assistant/src/ui/gpui/path_util.rs deleted file mode 100644 index 99993fc1..00000000 --- a/crates/code_assistant/src/ui/gpui/path_util.rs +++ /dev/null @@ -1,57 +0,0 @@ -use std::path::Path; - -/// Helper trait to extend Path with icon-related functionality. -pub trait PathExt { - /// Returns either the suffix if available, or the file stem otherwise to determine - /// which file icon to use. - fn icon_stem_or_suffix(&self) -> Option<&str>; -} - -impl> PathExt for T { - fn icon_stem_or_suffix(&self) -> Option<&str> { - let path = self.as_ref(); - let file_name = path.file_name()?.to_str()?; - - // For hidden files (Unix style), return the name without the leading dot - if file_name.starts_with('.') { - return file_name.strip_prefix('.'); - } - - // Try to get extension, or fall back to file stem - path.extension() - .and_then(|e| e.to_str()) - .or_else(|| path.file_stem()?.to_str()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_icon_stem_or_suffix() { - // No dots in name - let path = Path::new("/a/b/c/file_name.rs"); - assert_eq!(path.icon_stem_or_suffix(), Some("rs")); - - // Single dot in name - let path = Path::new("/a/b/c/file.name.rs"); - assert_eq!(path.icon_stem_or_suffix(), Some("rs")); - - // No suffix - let path = Path::new("/a/b/c/file"); - assert_eq!(path.icon_stem_or_suffix(), Some("file")); - - // Multiple dots in name - let path = Path::new("/a/b/c/long.file.name.rs"); - assert_eq!(path.icon_stem_or_suffix(), Some("rs")); - - // Hidden file, no extension - let path = Path::new("/a/b/c/.gitignore"); - assert_eq!(path.icon_stem_or_suffix(), Some("gitignore")); - - // Hidden file, with extension - let path = Path::new("/a/b/c/.eslintrc.js"); - assert_eq!(path.icon_stem_or_suffix(), Some("eslintrc.js")); - } -} diff --git a/crates/code_assistant/src/ui/gpui/plan_banner.rs b/crates/code_assistant/src/ui/gpui/plan_banner.rs index 5a1351c0..d1e2eff8 100644 --- a/crates/code_assistant/src/ui/gpui/plan_banner.rs +++ b/crates/code_assistant/src/ui/gpui/plan_banner.rs @@ -108,19 +108,18 @@ impl Render for PlanBanner { .border_t_1() .border_color(cx.theme().border) .px_4() - .py_3() + .py(px(6.)) .gap_2() .text_size(px(11.)) .line_height(px(15.)) .child(header); if self.collapsed { - let summary_color = if highlight_summary { - cx.theme().info - } else { - cx.theme().muted_foreground - }; - container = container.child(div().text_color(summary_color).child(summary_text)); + // Only show summary line if there's something meaningful (e.g., in-progress item) + // The header already shows the item count, so don't repeat it + if highlight_summary { + container = container.child(div().text_color(cx.theme().info).child(summary_text)); + } } else { let markdown = build_plan_markdown(plan); if !markdown.is_empty() { diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index be5b127c..0166283f 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -1,8 +1,7 @@ use super::auto_scroll::AutoScrollContainer; use super::chat_sidebar::{ChatSidebar, ChatSidebarEvent}; -use super::file_icons; + use super::input_area::{InputArea, InputAreaEvent}; -use super::memory::MemoryView; use super::messages::MessagesView; use super::plan_banner; use super::theme; @@ -15,21 +14,19 @@ use gpui::{ Context, Entity, FocusHandle, Focusable, MouseButton, MouseUpEvent, SharedString, Subscription, Transformation, }; -use gpui_component::ActiveTheme; + +use gpui_component::{ActiveTheme, Icon, Sizable, Size}; use std::collections::HashMap; use tracing::{debug, error, trace, warn}; // Root View - handles overall layout and coordination pub struct RootView { input_area: Entity, - memory_view: Entity, chat_sidebar: Entity, auto_scroll_container: Entity>, plan_banner: Entity, recent_keystrokes: Vec, focus_handle: FocusHandle, - // Memory view state - memory_collapsed: bool, // Chat sidebar state chat_collapsed: bool, current_session_id: Option, @@ -44,7 +41,6 @@ pub struct RootView { impl RootView { pub fn new( - memory_view: Entity, messages_view: Entity, chat_sidebar: Entity, window: &mut gpui::Window, @@ -74,13 +70,11 @@ impl RootView { let mut root_view = Self { input_area, - memory_view, chat_sidebar, auto_scroll_container, plan_banner, recent_keystrokes: vec![], focus_handle: cx.focus_handle(), - memory_collapsed: false, chat_collapsed: false, // Chat sidebar is visible by default current_session_id: None, chat_sessions: Vec::new(), @@ -97,16 +91,6 @@ impl RootView { root_view } - pub fn on_toggle_memory( - &mut self, - _: &MouseUpEvent, - _window: &mut gpui::Window, - cx: &mut Context, - ) { - self.memory_collapsed = !self.memory_collapsed; - cx.notify(); - } - pub fn on_toggle_chat_sidebar( &mut self, _: &MouseUpEvent, @@ -749,41 +733,35 @@ impl Render for RootView { .flex() .flex_row() .items_center() - .justify_between() - .px_4() - // Left side - title + .justify_start() + // Left padding for macOS traffic lights (doubled for more space) + .pl(px(86.)) + // Left side - controls .child( div() .flex() .items_center() - .text_color(cx.theme().muted_foreground) - .gap_2() - .pl(px(80.)) - .child("Code Assistant"), - ) - // Right side - controls - .child( - div() - .flex() - .items_center() - .gap_2() + .gap_1() // Chat sidebar toggle button .child( div() - .size(px(32.)) + .size(px(28.)) .rounded_sm() .flex() .items_center() .justify_center() .cursor_pointer() .hover(|s| s.bg(cx.theme().muted)) - .child(file_icons::render_icon( - &file_icons::get() - .get_type_icon(file_icons::MESSAGE_BUBBLES), - 18.0, - cx.theme().muted_foreground, - "💬", - )) + .child( + Icon::default() + .path(SharedString::from(if self.chat_collapsed { + "icons/panel_left_open.svg" + } else { + "icons/panel_left_close.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) .on_mouse_up( MouseButton::Left, cx.listener(Self::on_toggle_chat_sidebar), @@ -792,68 +770,41 @@ impl Render for RootView { // Theme toggle button .child( div() - .size(px(32.)) + .size(px(28.)) .rounded_sm() .flex() .items_center() .justify_center() .cursor_pointer() .hover(|s| s.bg(cx.theme().muted)) - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(if cx.theme().is_dark() { - file_icons::THEME_LIGHT - } else { - file_icons::THEME_DARK - }), - 18.0, - cx.theme().muted_foreground, - if cx.theme().is_dark() { "*" } else { "c" }, - )) - .on_mouse_up( - MouseButton::Left, - cx.listener(Self::on_toggle_theme), - ), - ) - // Memory toggle button - .child( - div() - .size(px(32.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) - .child(file_icons::render_icon( - &file_icons::get().get_type_icon( - if self.memory_collapsed { - file_icons::PANEL_RIGHT_OPEN + .child( + Icon::default() + .path(SharedString::from(if cx.theme().is_dark() { + "icons/theme_light.svg" } else { - file_icons::PANEL_RIGHT_CLOSE - }, - ), - 18.0, - cx.theme().muted_foreground, - "<>", - )) + "icons/theme_dark.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) .on_mouse_up( MouseButton::Left, - cx.listener(Self::on_toggle_memory), + cx.listener(Self::on_toggle_theme), ), ), ), ) - // Main content area with chat sidebar, messages+input, and memory sidebar (3-column layout) + // Main content area with chat sidebar and messages+input (2-column layout) .child( div() .size_full() .min_h_0() .flex() - .flex_row() // 3-column layout: chat | messages+input | memory + .flex_row() // 2-column layout: chat | messages+input // Left sidebar: Chat sessions .child(self.chat_sidebar.clone()) .child( - // Center: Messages and input (content area) with floating popover + // Messages and input (content area) with floating popover div() .relative() // For popover positioning .bg(cx.theme().popover) @@ -879,57 +830,7 @@ impl Render for RootView { .border_color(cx.theme().border) .child(self.input_area.clone()), ), - ) - // Right sidebar with memory view - only show if not collapsed - .when(!self.memory_collapsed, |s| { - s.child( - div() - .id("memory-sidebar") - .flex_none() - .w(px(260.)) - .h_full() - .bg(cx.theme().sidebar) - .border_l_1() - .border_color(cx.theme().sidebar_border) - .overflow_hidden() - .flex() - .flex_col() - .child(self.memory_view.clone()), - ) - }) - // When memory view is collapsed, show only a narrow bar - .when(self.memory_collapsed, |s| { - s.child( - div() - .id("collapsed-memory-sidebar") - .flex_none() - .w(px(40.)) - .h_full() - .bg(cx.theme().sidebar) - .border_l_1() - .border_color(cx.theme().sidebar_border) - .flex() - .flex_col() - .items_center() - .gap_2() - .py_2() - .child( - div() - .size(px(24.)) - .rounded_full() - .flex() - .items_center() - .justify_center() - .child(file_icons::render_icon( - &file_icons::get() - .get_type_icon(file_icons::WORKING_MEMORY), - 16.0, - cx.theme().muted_foreground, - "🧠", - )), - ), - ) - }), + ), ) } } diff --git a/crates/code_assistant/src/ui/terminal/state.rs b/crates/code_assistant/src/ui/terminal/state.rs index 05cc3986..40499f3c 100644 --- a/crates/code_assistant/src/ui/terminal/state.rs +++ b/crates/code_assistant/src/ui/terminal/state.rs @@ -1,11 +1,10 @@ use crate::persistence::ChatMetadata; use crate::session::instance::SessionActivityState; -use crate::types::{PlanState, WorkingMemory}; +use crate::types::PlanState; use sandbox::SandboxPolicy; use std::collections::HashMap; pub struct AppState { - pub working_memory: Option, pub plan: Option, pub plan_expanded: bool, pub plan_dirty: bool, @@ -23,7 +22,6 @@ pub struct AppState { impl AppState { pub fn new() -> Self { Self { - working_memory: None, plan: None, plan_expanded: false, plan_dirty: true, diff --git a/crates/code_assistant/src/ui/terminal/ui.rs b/crates/code_assistant/src/ui/terminal/ui.rs index 4e1cfece..79fa5555 100644 --- a/crates/code_assistant/src/ui/terminal/ui.rs +++ b/crates/code_assistant/src/ui/terminal/ui.rs @@ -100,10 +100,7 @@ impl UserInterface for TerminalTuiUI { .insert(tool_result.tool_id, tool_result.status); } } - UiEvent::UpdateMemory { memory } => { - debug!("Updating memory"); - state.working_memory = Some(memory); - } + UiEvent::UpdatePlan { plan } => { debug!("Updating plan"); let plan_clone = plan.clone(); @@ -310,6 +307,35 @@ impl UserInterface for TerminalTuiUI { renderer_guard.clear_error(); } } + // Resource events - logged for debugging, can be extended for features like "follow mode" + UiEvent::ResourceLoaded { project, path } => { + tracing::trace!( + "ResourceLoaded event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::ResourceWritten { project, path } => { + tracing::trace!( + "ResourceWritten event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::DirectoryListed { project, path } => { + tracing::trace!( + "DirectoryListed event - project: {}, path: {}", + project, + path.display() + ); + } + UiEvent::ResourceDeleted { project, path } => { + tracing::trace!( + "ResourceDeleted event - project: {}, path: {}", + project, + path.display() + ); + } _ => { // For other events, just log them debug!("Unhandled event: {:?}", event); diff --git a/crates/code_assistant/src/ui/ui_events.rs b/crates/code_assistant/src/ui/ui_events.rs index 18a3e3b4..b63bd0ae 100644 --- a/crates/code_assistant/src/ui/ui_events.rs +++ b/crates/code_assistant/src/ui/ui_events.rs @@ -1,9 +1,10 @@ use crate::persistence::{ChatMetadata, DraftAttachment}; use crate::session::instance::SessionActivityState; -use crate::types::{PlanState, WorkingMemory}; +use crate::types::PlanState; use crate::ui::gpui::elements::MessageRole; use crate::ui::{DisplayFragment, ToolStatus}; use sandbox::SandboxPolicy; +use std::path::PathBuf; /// Data for a complete message with its display fragments #[derive(Debug, Clone)] @@ -56,8 +57,6 @@ pub enum UiEvent { AddImage { media_type: String, data: String }, /// Append streaming tool output AppendToolOutput { tool_id: String, chunk: String }, - /// Update the working memory view - UpdateMemory { memory: WorkingMemory }, /// Update the session plan display UpdatePlan { plan: PlanState }, /// Set all messages at once (for session loading, clears existing) @@ -119,4 +118,14 @@ pub enum UiEvent { UpdateCurrentModel { model_name: String }, /// Update the current sandbox selection in the UI UpdateSandboxPolicy { policy: SandboxPolicy }, + + // === Resource Events (for tool operations) === + /// A file was loaded/read by a tool + ResourceLoaded { project: String, path: PathBuf }, + /// A file was written/modified by a tool + ResourceWritten { project: String, path: PathBuf }, + /// A directory was listed by a tool + DirectoryListed { project: String, path: PathBuf }, + /// A file was deleted by a tool + ResourceDeleted { project: String, path: PathBuf }, } diff --git a/crates/fs_explorer/src/explorer.rs b/crates/fs_explorer/src/explorer.rs index 7fadbdba..212122a3 100644 --- a/crates/fs_explorer/src/explorer.rs +++ b/crates/fs_explorer/src/explorer.rs @@ -7,7 +7,7 @@ use command_executor::CommandExecutor; use ignore::WalkBuilder; use path_clean::PathClean; use regex::RegexBuilder; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::ffi::OsString; use std::fmt; use std::path::{Path, PathBuf}; @@ -72,11 +72,10 @@ struct SearchSection { } /// Handles file system operations for code exploration + #[derive(Clone)] pub struct Explorer { root_dir: PathBuf, - // Track which paths were explicitly listed - expanded_paths: HashSet, // Track which files had which encoding file_encodings: Arc>>, // Track file format information (encoding + line ending) @@ -157,9 +156,9 @@ impl Explorer { /// * `root_dir` - The root directory to explore pub fn new(root_dir: PathBuf) -> Self { let canonical_root = root_dir.canonicalize().unwrap_or_else(|_| root_dir.clean()); + Self { root_dir: canonical_root, - expanded_paths: HashSet::new(), file_encodings: Arc::new(RwLock::new(HashMap::new())), file_formats: Arc::new(RwLock::new(HashMap::new())), } @@ -214,18 +213,13 @@ impl Explorer { } fn expand_directory( - &mut self, path: &Path, entry: &mut FileTreeEntry, current_depth: usize, max_depth: usize, ) -> Result<()> { - // Expand if either: - // - Within max_depth during initial load - // - The path was explicitly listed before - let should_expand = current_depth < max_depth || self.expanded_paths.contains(path); - - if !should_expand { + // Expand if within max_depth + if current_depth >= max_depth { entry.is_expanded = false; return Ok(()); } @@ -273,7 +267,7 @@ impl Explorer { }; if is_dir { - self.expand_directory(entry_path, &mut child_entry, current_depth + 1, max_depth)?; + Self::expand_directory(entry_path, &mut child_entry, current_depth + 1, max_depth)?; } entry.children.insert(child_entry.name.clone(), child_entry); @@ -433,7 +427,7 @@ impl CodeExplorer for Explorer { }; let root_dir = &self.root_dir.clone(); - self.expand_directory(root_dir, &mut root, 0, max_depth)?; + Self::expand_directory(root_dir, &mut root, 0, max_depth)?; Ok(root) } @@ -547,14 +541,12 @@ impl CodeExplorer for Explorer { async fn list_files(&mut self, path: &Path, max_depth: Option) -> Result { let resolved = self.resolve_path(path)?; let path = resolved.as_path(); + // Check if the path exists before proceeding if !path.exists() { return Err(anyhow::anyhow!("Path not found")); } - // Remember that this path was explicitly listed - self.expanded_paths.insert(resolved.clone()); - let mut entry = FileTreeEntry { name: path .file_name() @@ -571,7 +563,7 @@ impl CodeExplorer for Explorer { }; if path.is_dir() { - self.expand_directory(path, &mut entry, 0, max_depth.unwrap_or(usize::MAX))?; + Self::expand_directory(path, &mut entry, 0, max_depth.unwrap_or(usize::MAX))?; } Ok(entry) diff --git a/docs/remove-working-memory-ui-plan.md b/docs/remove-working-memory-ui-plan.md new file mode 100644 index 00000000..6d8050a1 --- /dev/null +++ b/docs/remove-working-memory-ui-plan.md @@ -0,0 +1,302 @@ +# Remove Working Memory UI Implementation Plan + +## Overview + +This plan outlines the removal of the Working Memory UI from the GPUI version of the application, while preserving the event-driven notification system for file and directory operations. The goal is to reduce coupling between the tool registry/execution module and the agent code, enabling the tools module to eventually be extracted into its own crate. + +## Current State Analysis + +### WorkingMemory Usage + +The `WorkingMemory` struct currently serves multiple purposes: + +1. **UI Display**: Showing loaded resources and file trees in the GPUI sidebar +2. **Session Persistence**: Stored in `SessionState` and `ChatSession` for session recovery +3. **System Prompt Generation**: Provides `file_trees` and `available_projects` for the system prompt +4. **Resource Tracking in Tools**: Tools update `loaded_resources` when files are read/written + +### Components Using WorkingMemory + +| Component | Location | Usage | +|-----------|----------|-------| +| `MemoryView` | `ui/gpui/memory.rs` | UI display (to be removed) | +| `Agent` | `agent/runner.rs` | System prompt generation, memory management | +| `SessionState` | `session/mod.rs` | Persistence structure | +| `ChatSession` | `persistence.rs` | Persistence to disk | +| `ToolContext` | `tools/core/tool.rs` | Passed to tools for resource tracking | +| Tools | `tools/impls/*.rs` | Update `loaded_resources`, `file_trees`, `expanded_directories` | +| `UiEvent::UpdateMemory` | `ui/ui_events.rs` | Event to update UI (to be removed) | +| `AppState` | `ui/terminal/state.rs` | Terminal UI state (keeps field, unused) | + +## Design Goals + +1. **Remove the Working Memory UI panel** from GPUI entirely +2. **Preserve event emission** when files are loaded or directories listed +3. **Stop tracking visited directories** in Explorer instances +4. **Remove the `WorkingMemory` struct** and its usage across the codebase +5. **Reduce coupling** so tools module could become an independent crate + +## Implementation Phases + +### Phase 1: Add Resource Events to UiEvent (Low Risk) + +**Goal**: Extend the existing `UiEvent` enum to include resource operation notifications. This follows the established pattern where `UiEvent` carries state updates to the UI. + +#### Design Rationale + +The codebase already has two event mechanisms: +- `DisplayFragment` - for real-time streaming content (text, tool invocations) +- `UiEvent` - for discrete state updates (session changes, tool status, memory updates) + +Resource operations are discrete state changes, so they belong in `UiEvent`. This avoids introducing new traits or coupling mechanisms. + +#### Tasks + +1. **Add new variants to `UiEvent`** in `ui/ui_events.rs`: + ```rust + /// A file was loaded/read by a tool + ResourceLoaded { + project: String, + path: PathBuf, + }, + /// A file was written/modified by a tool + ResourceWritten { + project: String, + path: PathBuf, + }, + /// A directory was listed by a tool + DirectoryListed { + project: String, + path: PathBuf, + }, + /// A file was deleted by a tool + ResourceDeleted { + project: String, + path: PathBuf, + }, + ``` + +2. **Handle new events in GPUI** (`ui/gpui/mod.rs`): + - Add match arms that log the events (for now) + - These can be extended later for features like "follow mode" + +3. **Handle new events in Terminal UI** (`ui/terminal/ui.rs`): + - Add match arms that log the events + +### Phase 2: Update Tool Implementations (Medium Risk) + +**Goal**: Modify tools to emit `UiEvent`s via `ToolContext.ui` instead of updating WorkingMemory directly. + +#### Tasks per Tool + +| Tool | Current WorkingMemory Usage | New UiEvent | +|------|---------------------------|-------------| +| `read_files` | Insert into `loaded_resources` | `ResourceLoaded` | +| `write_file` | Insert into `loaded_resources` | `ResourceWritten` | +| `edit` | Insert into `loaded_resources` | `ResourceWritten` | +| `replace_in_file` | Insert into `loaded_resources` | `ResourceWritten` | +| `delete_files` | Could remove from `loaded_resources` | `ResourceDeleted` | +| `list_files` | Update `file_trees`, `expanded_directories`, `available_projects` | `DirectoryListed` | + +#### Detailed Changes + +1. **read_files.rs** (~5 lines): + - Remove: `working_memory.loaded_resources.insert(...)` + - Add: Send `UiEvent::ResourceLoaded` via `context.ui` + +2. **write_file.rs** (~5 lines): + - Remove: `working_memory.loaded_resources.insert(...)` + - Add: Send `UiEvent::ResourceWritten` via `context.ui` + +3. **edit.rs** (~5 lines): + - Same pattern as write_file + +4. **replace_in_file.rs** (~5 lines): + - Same pattern as edit + +5. **delete_files.rs** (~5 lines): + - Add: Send `UiEvent::ResourceDeleted` via `context.ui` + - (Currently doesn't update WorkingMemory) + +6. **list_files.rs** (~20 lines): + - Remove: All `file_trees`, `expanded_directories`, `available_projects` updates + - Add: Send `UiEvent::DirectoryListed` via `context.ui` + +#### Note on Event Emission + +Tools already have access to `context.ui: Option<&'a dyn UserInterface>` for streaming output. We'll reuse this for resource events. The pattern is: + +```rust +if let Some(ui) = context.ui { + let _ = ui.send_event(UiEvent::ResourceLoaded { + project: input.project.clone(), + path: path.clone(), + }).await; +} +``` + +Since `send_event` is async, we'll need to handle this appropriately (tools are already async). + +### Phase 3: Update ToolContext and Agent (Medium Risk) + +**Goal**: Remove `working_memory` from `ToolContext` and `Agent`. Tools already have `ui` access for events. + +#### Tasks + +1. **Update `ToolContext`** in `tools/core/tool.rs`: + - Remove: `working_memory: Option<&'a mut WorkingMemory>` + - The `ui` field already exists and will be used for events + +2. **Update `execute_tool()`** in `agent/runner.rs`: + - Remove: `working_memory: Some(&mut self.working_memory)` from context construction + +3. **Remove `working_memory` field** from `Agent` struct + +4. **Update `init_working_memory()`** and `init_working_memory_projects()`: + - These methods currently build file trees for system prompt + - Keep the file tree building logic but store trees differently (see Phase 4) + +### Phase 4: Preserve System Prompt Information (Medium Risk) + +**Goal**: Keep the ability to show available projects and initial file tree in system prompts without WorkingMemory. + +#### Tasks + +1. **Add dedicated fields to `Agent`** for system prompt data: + ```rust + struct Agent { + // ... existing fields ... + available_projects: Vec, + initial_file_tree: Option, // For initial project only + initial_project: String, + } + ``` + +2. **Update `get_system_prompt()`** to use these fields instead of `self.working_memory.available_projects` etc. + +3. **Update `init_working_memory_projects()`** → rename to `init_projects()`: + - Still queries ProjectManager for available projects + - Still creates initial file tree for the initial project + - Stores in Agent fields instead of WorkingMemory + +### Phase 5: Remove GPUI Working Memory UI (Low Risk) + +**Goal**: Remove all UI components related to Working Memory display. + +#### Tasks + +1. **Delete `ui/gpui/memory.rs`** entirely + +2. **Update `ui/gpui/mod.rs`**: + - Remove `mod memory;` + - Remove `pub use memory::MemoryView;` + - Remove `working_memory` field from `Gpui` struct + - Remove `UiEvent::UpdateMemory` handling + +3. **Update `ui/gpui/root.rs`**: + - Remove `memory_view: Entity` field + - Remove memory sidebar toggle button + - Remove memory sidebar rendering (both expanded and collapsed states) + - Remove `memory_collapsed` state field + +4. **Update `ui/ui_events.rs`**: + - Remove `UpdateMemory { memory: WorkingMemory }` variant + +5. **Update `ui/terminal/state.rs`**: + - Remove `working_memory: Option` field (or keep as dead code if minimal impact) + +### Phase 6: Remove WorkingMemory from Persistence (Medium Risk) + +**Goal**: Stop persisting WorkingMemory in sessions since it's no longer used. + +#### Tasks + +1. **Update `SessionState` struct** in `session/mod.rs`: + - Remove `working_memory: WorkingMemory` field + +2. **Update `ChatSession` struct** in `persistence.rs`: + - Remove `working_memory: WorkingMemory` field + - Note: Existing session files will have this field; handle gracefully during deserialization + +3. **Update `FileStatePersistence::save_agent_state()`** in `agent/persistence.rs`: + - Remove working_memory from saved state + +4. **Update `Agent::load_from_session_state()`**: + - Remove `self.working_memory = session_state.working_memory;` + +5. **Update test fixtures** in `tests/mocks.rs` and other test files + +### Phase 7: Remove Explorer Directory Tracking (Low Risk) + +**Goal**: Remove `expanded_paths` from Explorer since it's no longer used for WorkingMemory. + +The `expanded_paths` field is a remnant from an earlier version where the full directory tree (with expanded/collapsed state) was shown on every turn. Now that each `list_files` call returns exactly what the model requested, this tracking is unnecessary. + +#### Tasks + +1. **Update `Explorer` struct** in `fs_explorer/src/explorer.rs`: + - Remove: `expanded_paths: HashSet` field + - Simplify `expand_directory()` to only consider `max_depth`, not `expanded_paths` + - The condition `current_depth < max_depth || self.expanded_paths.contains(path)` becomes just `current_depth < max_depth` + +### Phase 8: Delete WorkingMemory Type (Low Risk) + +**Goal**: Final cleanup - remove the WorkingMemory type definition. + +#### Tasks + +1. **Update `types.rs`**: + - Remove `WorkingMemory` struct + - Remove `LoadedResource` enum (unless used elsewhere) + - Remove `tuple_key_map` module + +2. **Search and fix any remaining references** + +## Migration Notes + +### Backward Compatibility + +- Existing session files contain `working_memory` field +- Use `#[serde(default)]` on new structs to allow loading old sessions +- Old `working_memory` data in session files will be ignored + +### Test Updates Required + +- `tests/format_on_save_tests.rs`: Update tests that verify WorkingMemory updates +- `tests/mocks.rs`: Remove `ToolTestFixture::with_working_memory()` method +- `agent/tests.rs`: Update agent tests that check WorkingMemory state +- Tool-specific tests: Update to verify events instead of WorkingMemory mutations + +## Resolved Questions + +1. **Event mechanism**: Use existing `UiEvent` enum with new variants. This follows the established pattern and avoids new traits/coupling. + +2. **Explorer expanded_paths**: Remove. It's a remnant of an earlier architecture where file trees accumulated across turns. + +3. **Terminal UI working_memory field**: Remove for cleanliness. + +4. **Event granularity**: Just path in events. The full tree/content is in tool output already. + +5. **Events to UI**: Events flow to UI for future extensibility (e.g., ACP follow mode). + +## Risk Assessment + +| Phase | Risk Level | Reason | +|-------|------------|--------| +| 1 | Low | Adding new types, no breaking changes | +| 2 | Medium | Modifying tool implementations | +| 3 | Medium | Agent structure changes | +| 4 | Medium | System prompt generation changes | +| 5 | Low | UI removal, isolated changes | +| 6 | Medium | Persistence schema changes | +| 7 | Low | Internal Explorer cleanup | +| 8 | Low | Final type deletion | + +## Success Criteria + +1. GPUI application runs without Working Memory sidebar +2. All existing tests pass (with necessary updates) +3. Session persistence works (loading old sessions, saving new sessions) +4. System prompt still contains project information and initial file tree +5. Tools emit appropriate events when operating on files +6. No references to `WorkingMemory` remain in codebase