From fdbb11e6699ffbc129a2694cdd8f439dd53d1a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 16:19:31 +0100 Subject: [PATCH 01/12] Inititial plan --- docs/remove-working-memory-ui-plan.md | 302 ++++++++++++++++++++++++++ 1 file changed, 302 insertions(+) create mode 100644 docs/remove-working-memory-ui-plan.md 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 From 6b2382e49352e0879293fb4a5ba224c00f810aa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 17:09:01 +0100 Subject: [PATCH 02/12] Remove everything working memory related --- crates/code_assistant/src/acp/ui.rs | 31 ++ crates/code_assistant/src/agent/runner.rs | 89 ++-- crates/code_assistant/src/mcp/handler.rs | 1 - crates/code_assistant/src/session/instance.rs | 4 - .../src/tests/format_on_save_tests.rs | 33 +- crates/code_assistant/src/tests/mocks.rs | 23 - crates/code_assistant/src/tools/core/tool.rs | 4 +- .../src/tools/impls/delete_files.rs | 52 +-- crates/code_assistant/src/tools/impls/edit.rs | 84 +--- .../src/tools/impls/list_files.rs | 92 +--- .../src/tools/impls/perplexity_ask.rs | 51 +-- .../src/tools/impls/read_files.rs | 61 ++- .../src/tools/impls/replace_in_file.rs | 43 +- .../src/tools/impls/web_fetch.rs | 23 +- .../src/tools/impls/write_file.rs | 74 ++- crates/code_assistant/src/ui/gpui/memory.rs | 423 ------------------ crates/code_assistant/src/ui/gpui/mod.rs | 61 ++- crates/code_assistant/src/ui/gpui/root.rs | 102 +---- .../code_assistant/src/ui/terminal/state.rs | 4 +- crates/code_assistant/src/ui/terminal/ui.rs | 35 +- crates/code_assistant/src/ui/ui_events.rs | 11 + crates/fs_explorer/src/explorer.rs | 19 +- 22 files changed, 314 insertions(+), 1006 deletions(-) delete mode 100644 crates/code_assistant/src/ui/gpui/memory.rs diff --git a/crates/code_assistant/src/acp/ui.rs b/crates/code_assistant/src/acp/ui.rs index e9da2562..f8962724 100644 --- a/crates/code_assistant/src/acp/ui.rs +++ b/crates/code_assistant/src/acp/ui.rs @@ -668,6 +668,37 @@ 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 { .. } 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..3cecadca 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 { // 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/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..0de17793 100644 --- a/crates/code_assistant/src/ui/gpui/mod.rs +++ b/crates/code_assistant/src/ui/gpui/mod.rs @@ -9,7 +9,6 @@ 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; @@ -21,7 +20,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 +38,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 +63,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 +209,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 +263,6 @@ impl Gpui { Self { message_queue, - working_memory, plan_state, event_sender, event_receiver, @@ -303,7 +298,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 +395,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 +430,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,11 +564,9 @@ 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::UpdateMemory { memory: _ } => { + // Memory UI has been removed - this event is ignored } UiEvent::UpdatePlan { plan } => { if let Ok(mut plan_guard) = self.plan_state.lock() { @@ -992,6 +975,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/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index be5b127c..3d59f800 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -2,7 +2,6 @@ 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; @@ -22,14 +21,11 @@ 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 +40,6 @@ pub struct RootView { impl RootView { pub fn new( - memory_view: Entity, messages_view: Entity, chat_sidebar: Entity, window: &mut gpui::Window, @@ -74,13 +69,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 +90,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, @@ -813,47 +796,20 @@ impl Render for RootView { 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 - } else { - file_icons::PANEL_RIGHT_CLOSE - }, - ), - 18.0, - cx.theme().muted_foreground, - "<>", - )) - .on_mouse_up( - MouseButton::Left, - cx.listener(Self::on_toggle_memory), - ), ), ), ) - // 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 +835,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..6ccd896d 100644 --- a/crates/code_assistant/src/ui/terminal/ui.rs +++ b/crates/code_assistant/src/ui/terminal/ui.rs @@ -100,9 +100,9 @@ impl UserInterface for TerminalTuiUI { .insert(tool_result.tool_id, tool_result.status); } } - UiEvent::UpdateMemory { memory } => { - debug!("Updating memory"); - state.working_memory = Some(memory); + + UiEvent::UpdateMemory { memory: _ } => { + // Memory UI has been removed - this event is ignored } UiEvent::UpdatePlan { plan } => { debug!("Updating plan"); @@ -310,6 +310,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..30ca8ef3 100644 --- a/crates/code_assistant/src/ui/ui_events.rs +++ b/crates/code_assistant/src/ui/ui_events.rs @@ -4,6 +4,7 @@ use crate::types::{PlanState, WorkingMemory}; 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)] @@ -119,4 +120,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..8b97c721 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())), } @@ -220,12 +219,8 @@ impl Explorer { 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(()); } @@ -547,14 +542,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() From fa1863e97984488d9d533e8c344ac62955dd8f5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 17:24:19 +0100 Subject: [PATCH 03/12] Cleanup unused stuff --- crates/code_assistant/src/acp/ui.rs | 3 +- .../src/tools/impls/read_files.rs | 2 +- crates/code_assistant/src/types.rs | 7 -- .../code_assistant/src/ui/gpui/file_icons.rs | 107 +----------------- crates/code_assistant/src/ui/gpui/mod.rs | 4 - .../code_assistant/src/ui/gpui/path_util.rs | 57 ---------- crates/code_assistant/src/ui/terminal/ui.rs | 3 - crates/code_assistant/src/ui/ui_events.rs | 4 +- crates/fs_explorer/src/explorer.rs | 7 +- 9 files changed, 7 insertions(+), 187 deletions(-) delete mode 100644 crates/code_assistant/src/ui/gpui/path_util.rs diff --git a/crates/code_assistant/src/acp/ui.rs b/crates/code_assistant/src/acp/ui.rs index f8962724..d4d2fb7a 100644 --- a/crates/code_assistant/src/acp/ui.rs +++ b/crates/code_assistant/src/acp/ui.rs @@ -700,8 +700,7 @@ impl UserInterface for ACPUserUI { } // 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/tools/impls/read_files.rs b/crates/code_assistant/src/tools/impls/read_files.rs index 3cecadca..046c8c6f 100644 --- a/crates/code_assistant/src/tools/impls/read_files.rs +++ b/crates/code_assistant/src/tools/impls/read_files.rs @@ -210,7 +210,7 @@ impl Tool for ReadFilesTool { // Emit resource events for loaded files if let Some(ui) = context.ui { - for (path, _) in &loaded_files { + 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("")) { 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/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index e43d05db..7fcd9c8b 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,29 +22,15 @@ 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 @@ -57,7 +40,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 +65,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 +112,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/mod.rs b/crates/code_assistant/src/ui/gpui/mod.rs index 0de17793..3d527ac0 100644 --- a/crates/code_assistant/src/ui/gpui/mod.rs +++ b/crates/code_assistant/src/ui/gpui/mod.rs @@ -12,7 +12,6 @@ pub mod input_area; mod messages; pub mod model_selector; pub mod parameter_renderers; -mod path_util; mod plan_banner; mod root; pub mod sandbox_selector; @@ -565,9 +564,6 @@ impl Gpui { }); } - UiEvent::UpdateMemory { memory: _ } => { - // Memory UI has been removed - this event is ignored - } UiEvent::UpdatePlan { plan } => { if let Ok(mut plan_guard) = self.plan_state.lock() { *plan_guard = Some(plan); 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/terminal/ui.rs b/crates/code_assistant/src/ui/terminal/ui.rs index 6ccd896d..79fa5555 100644 --- a/crates/code_assistant/src/ui/terminal/ui.rs +++ b/crates/code_assistant/src/ui/terminal/ui.rs @@ -101,9 +101,6 @@ impl UserInterface for TerminalTuiUI { } } - UiEvent::UpdateMemory { memory: _ } => { - // Memory UI has been removed - this event is ignored - } UiEvent::UpdatePlan { plan } => { debug!("Updating plan"); let plan_clone = plan.clone(); diff --git a/crates/code_assistant/src/ui/ui_events.rs b/crates/code_assistant/src/ui/ui_events.rs index 30ca8ef3..b63bd0ae 100644 --- a/crates/code_assistant/src/ui/ui_events.rs +++ b/crates/code_assistant/src/ui/ui_events.rs @@ -1,6 +1,6 @@ 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; @@ -57,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) diff --git a/crates/fs_explorer/src/explorer.rs b/crates/fs_explorer/src/explorer.rs index 8b97c721..212122a3 100644 --- a/crates/fs_explorer/src/explorer.rs +++ b/crates/fs_explorer/src/explorer.rs @@ -213,7 +213,6 @@ impl Explorer { } fn expand_directory( - &mut self, path: &Path, entry: &mut FileTreeEntry, current_depth: usize, @@ -268,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); @@ -428,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) } @@ -564,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) From 7311c8c013cfacf8bc222f5fbcd298ffd9cde7b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 21:12:18 +0100 Subject: [PATCH 04/12] Rearrange UI a bit --- .../assets/icons/panel_left_close.svg | 1 + .../assets/icons/panel_left_open.svg | 1 + .../src/ui/gpui/chat_sidebar.rs | 31 +++++---- .../code_assistant/src/ui/gpui/file_icons.rs | 3 - crates/code_assistant/src/ui/gpui/root.rs | 65 +++++++++---------- 5 files changed, 52 insertions(+), 49 deletions(-) create mode 100644 crates/code_assistant/assets/icons/panel_left_close.svg create mode 100644 crates/code_assistant/assets/icons/panel_left_open.svg 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/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 7fcd9c8b..09fcc219 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -31,9 +31,6 @@ 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 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 pub const MESSAGE_BUBBLES: &str = "message_bubbles"; // message_bubbles.svg diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index 3d59f800..0166283f 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -1,6 +1,6 @@ use super::auto_scroll::AutoScrollContainer; use super::chat_sidebar::{ChatSidebar, ChatSidebarEvent}; -use super::file_icons; + use super::input_area::{InputArea, InputAreaEvent}; use super::messages::MessagesView; use super::plan_banner; @@ -14,7 +14,8 @@ 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}; @@ -732,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), @@ -775,23 +770,23 @@ 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" }, - )) + .child( + Icon::default() + .path(SharedString::from(if cx.theme().is_dark() { + "icons/theme_light.svg" + } else { + "icons/theme_dark.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) .on_mouse_up( MouseButton::Left, cx.listener(Self::on_toggle_theme), From f316592d12c7605f5bc17e708eaefe03e8913597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 21:33:40 +0100 Subject: [PATCH 05/12] Rearrange UI even more --- .../src/ui/gpui/chat_sidebar.rs | 92 +------ .../code_assistant/src/ui/gpui/input_area.rs | 115 --------- crates/code_assistant/src/ui/gpui/root.rs | 227 +++++++++++++----- 3 files changed, 170 insertions(+), 264 deletions(-) diff --git a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs index d5ba0525..18831ba2 100644 --- a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs +++ b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs @@ -8,9 +8,8 @@ use gpui::{ }; use gpui_component::scroll::ScrollbarAxis; -use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, Sizable, Size, StyledExt}; +use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, StyledExt}; use std::time::SystemTime; -use tracing::debug; /// Events emitted by individual ChatListItem components #[derive(Clone, Debug)] @@ -28,8 +27,6 @@ pub enum ChatSidebarEvent { SessionSelected { session_id: String }, /// User requested deletion of a chat session SessionDeleteRequested { session_id: String }, - /// User requested creation of a new chat session - NewSessionRequested { name: Option }, } /// Individual chat list item component @@ -458,22 +455,6 @@ impl ChatSidebar { cx.notify(); } - fn on_new_chat_click( - &mut self, - _: &MouseUpEvent, - _window: &mut gpui::Window, - cx: &mut Context, - ) { - debug!("New chat button clicked"); - 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 }); - } - /// Handle events from ChatListItem components fn on_chat_list_item_event( &mut self, @@ -507,37 +488,8 @@ 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 new chat button - div() - .id("collapsed-chat-sidebar") - .flex_none() - .w(px(40.)) - .h_full() - .bg(cx.theme().sidebar) - .border_r_1() - .border_color(cx.theme().sidebar_border) - .flex() - .flex_col() - .items_center() - .gap_2() - .py_2() - .child( - div() - .size(px(28.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .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)), - ) + // Collapsed view - completely invisible (no element rendered) + div().id("collapsed-chat-sidebar").size_0() } else { // Full sidebar view div() @@ -550,44 +502,6 @@ impl Render for ChatSidebar { .border_color(cx.theme().sidebar_border) .flex() .flex_col() - .child( - // Header with title and new chat button - div() - .flex_none() - .p_3() - .border_b_1() - .border_color(cx.theme().sidebar_border) - .flex() - .items_center() - .justify_between() - .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().foreground) - .child("Chats"), - ) - .child( - div() - .size(px(24.)) - .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::PLUS), - 14.0, - cx.theme().muted_foreground, - "+", - )) - .on_mouse_up( - MouseButton::Left, - cx.listener(Self::on_new_chat_click), - ), - ), - ) .child( // Chat list area - outer container with padding div().flex_1().min_h(px(0.)).child( diff --git a/crates/code_assistant/src/ui/gpui/input_area.rs b/crates/code_assistant/src/ui/gpui/input_area.rs index 5717554b..29a07e24 100644 --- a/crates/code_assistant/src/ui/gpui/input_area.rs +++ b/crates/code_assistant/src/ui/gpui/input_area.rs @@ -1,7 +1,5 @@ use super::attachment::{AttachmentEvent, AttachmentView}; use super::file_icons; -use super::model_selector::{ModelSelector, ModelSelectorEvent}; -use super::sandbox_selector::{SandboxSelector, SandboxSelectorEvent}; use crate::persistence::DraftAttachment; use base64::Engine; use gpui::{ @@ -10,7 +8,6 @@ use gpui::{ }; use gpui_component::input::{Input, InputEvent, InputState, Paste}; use gpui_component::ActiveTheme; -use sandbox::SandboxPolicy; /// Events emitted by the InputArea component #[derive(Clone, Debug)] @@ -31,19 +28,11 @@ pub enum InputAreaEvent { CancelRequested, /// Clear draft requested (before clearing input) ClearDraftRequested, - /// Model selection changed - ModelChanged { model_name: String }, - /// Sandbox mode changed - SandboxChanged { policy: SandboxPolicy }, } /// Self-contained input area component that handles text input and attachments pub struct InputArea { text_input: Entity, - model_selector: Entity, - sandbox_selector: Entity, - current_model: Option, - current_sandbox_policy: SandboxPolicy, attachments: Vec, attachment_views: Vec>, focus_handle: FocusHandle, @@ -53,8 +42,6 @@ pub struct InputArea { cancel_enabled: bool, // Subscriptions _input_subscription: Subscription, - _model_selector_subscription: Subscription, - _sandbox_selector_subscription: Subscription, } impl InputArea { @@ -70,22 +57,8 @@ impl InputArea { // Subscribe to text input events let input_subscription = cx.subscribe_in(&text_input, window, Self::on_input_event); - // Create the model selector - let model_selector = cx.new(|cx| ModelSelector::new(window, cx)); - let sandbox_selector = cx.new(|cx| SandboxSelector::new(window, cx)); - - // Subscribe to model selector events - let model_selector_subscription = - cx.subscribe_in(&model_selector, window, Self::on_model_selector_event); - let sandbox_selector_subscription = - cx.subscribe_in(&sandbox_selector, window, Self::on_sandbox_selector_event); - Self { text_input, - model_selector, - sandbox_selector, - current_model: None, - current_sandbox_policy: SandboxPolicy::DangerFullAccess, attachments: Vec::new(), attachment_views: Vec::new(), focus_handle: cx.focus_handle(), @@ -93,8 +66,6 @@ impl InputArea { agent_is_running: false, cancel_enabled: false, _input_subscription: input_subscription, - _model_selector_subscription: model_selector_subscription, - _sandbox_selector_subscription: sandbox_selector_subscription, } } @@ -116,47 +87,6 @@ impl InputArea { self.rebuild_attachment_views(cx); } - /// Sync the dropdown with the current model selection - pub fn set_current_model( - &mut self, - model_name: Option, - window: &mut Window, - cx: &mut Context, - ) { - self.current_model = model_name.clone(); - self.model_selector.update(cx, |selector, cx| { - selector.set_current_model(model_name, window, cx) - }); - } - - /// Read the currently selected model name - pub fn current_model(&self) -> Option { - self.current_model.clone() - } - - pub fn set_current_sandbox_policy( - &mut self, - policy: SandboxPolicy, - window: &mut Window, - cx: &mut Context, - ) { - self.current_sandbox_policy = policy.clone(); - self.sandbox_selector.update(cx, |selector, cx| { - selector.set_policy(policy, window, cx); - }); - } - - pub fn current_sandbox_policy(&self) -> SandboxPolicy { - self.current_sandbox_policy.clone() - } - - /// Ensure the model list stays up to date - #[allow(dead_code)] - pub fn refresh_models(&mut self, window: &mut Window, cx: &mut Context) { - self.model_selector - .update(cx, |selector, cx| selector.refresh_models(window, cx)); - } - /// Clear the input content pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { // Clear text input @@ -295,41 +225,6 @@ impl InputArea { } } - /// Handle model selector events - fn on_model_selector_event( - &mut self, - _model_selector: &Entity, - event: &ModelSelectorEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event { - ModelSelectorEvent::ModelChanged { model_name } => { - self.current_model = Some(model_name.clone()); - cx.emit(InputAreaEvent::ModelChanged { - model_name: model_name.clone(), - }); - } - } - } - - fn on_sandbox_selector_event( - &mut self, - _selector: &Entity, - event: &SandboxSelectorEvent, - _window: &mut Window, - cx: &mut Context, - ) { - match event { - SandboxSelectorEvent::PolicyChanged { policy } => { - self.current_sandbox_policy = policy.clone(); - cx.emit(InputAreaEvent::SandboxChanged { - policy: policy.clone(), - }); - } - } - } - /// Handle submit button click fn on_submit_click(&mut self, _: &MouseUpEvent, window: &mut Window, cx: &mut Context) { let content = self.text_input.read(cx).value().to_string(); @@ -477,16 +372,6 @@ impl InputArea { buttons }), ) - // Model selector row - .child( - div() - .flex() - .gap_2() - .px_2() - .pb_2() - .child(div().flex_1().flex().child(self.model_selector.clone())) - .child(div().flex_1().flex().child(self.sandbox_selector.clone())), - ) } } diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index 0166283f..259bf261 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -3,7 +3,9 @@ use super::chat_sidebar::{ChatSidebar, ChatSidebarEvent}; use super::input_area::{InputArea, InputAreaEvent}; use super::messages::MessagesView; +use super::model_selector::{ModelSelector, ModelSelectorEvent}; use super::plan_banner; +use super::sandbox_selector::{SandboxSelector, SandboxSelectorEvent}; use super::theme; use super::BackendEvent; use super::{CloseWindow, Gpui, UiEventSender}; @@ -15,7 +17,7 @@ use gpui::{ Transformation, }; -use gpui_component::{ActiveTheme, Icon, Sizable, Size}; +use gpui_component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; use std::collections::HashMap; use tracing::{debug, error, trace, warn}; @@ -25,6 +27,10 @@ pub struct RootView { chat_sidebar: Entity, auto_scroll_container: Entity>, plan_banner: Entity, + model_selector: Entity, + sandbox_selector: Entity, + current_model: Option, + current_sandbox_policy: sandbox::SandboxPolicy, recent_keystrokes: Vec, focus_handle: FocusHandle, // Chat sidebar state @@ -37,6 +43,8 @@ pub struct RootView { _input_area_subscription: Subscription, _plan_banner_subscription: Subscription, _chat_sidebar_subscription: Subscription, + _model_selector_subscription: Subscription, + _sandbox_selector_subscription: Subscription, } impl RootView { @@ -56,6 +64,10 @@ impl RootView { // Create the input area let input_area = cx.new(|cx| InputArea::new(window, cx)); + // Create model selector and sandbox selector for the title bar + let model_selector = cx.new(|cx| ModelSelector::new(window, cx)); + let sandbox_selector = cx.new(|cx| SandboxSelector::new(window, cx)); + // Subscribe to input area events let input_area_subscription = cx.subscribe_in(&input_area, window, Self::on_input_area_event); @@ -68,11 +80,23 @@ impl RootView { let plan_banner_subscription = cx.subscribe_in(&plan_banner, window, Self::on_plan_banner_event); + // Subscribe to model selector events + let model_selector_subscription = + cx.subscribe_in(&model_selector, window, Self::on_model_selector_event); + + // Subscribe to sandbox selector events + let sandbox_selector_subscription = + cx.subscribe_in(&sandbox_selector, window, Self::on_sandbox_selector_event); + let mut root_view = Self { input_area, chat_sidebar, auto_scroll_container, plan_banner, + model_selector, + sandbox_selector, + current_model: None, + current_sandbox_policy: sandbox::SandboxPolicy::DangerFullAccess, recent_keystrokes: vec![], focus_handle: cx.focus_handle(), chat_collapsed: false, // Chat sidebar is visible by default @@ -83,6 +107,8 @@ impl RootView { _input_area_subscription: input_area_subscription, _plan_banner_subscription: plan_banner_subscription, _chat_sidebar_subscription: chat_sidebar_subscription, + _model_selector_subscription: model_selector_subscription, + _sandbox_selector_subscription: sandbox_selector_subscription, }; // Request initial chat session list @@ -145,6 +171,84 @@ impl RootView { cx.notify(); } + fn on_new_chat_click( + &mut self, + _: &MouseUpEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + debug!("New chat button clicked from title bar"); + // Send event to create a new session + let gpui = cx + .try_global::() + .expect("Failed to obtain Gpui global"); + if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { + let _ = sender.try_send(BackendEvent::CreateNewSession { name: None }); + } else { + error!("Failed to lock backend event sender"); + } + } + + /// Handle ModelSelector events + fn on_model_selector_event( + &mut self, + _model_selector: &Entity, + event: &ModelSelectorEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + ModelSelectorEvent::ModelChanged { model_name } => { + debug!("Model selection changed to: {}", model_name); + self.current_model = Some(model_name.clone()); + + if let Some(session_id) = &self.current_session_id { + let gpui = cx + .try_global::() + .expect("Failed to obtain Gpui global"); + if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { + let _ = sender.try_send(BackendEvent::SwitchModel { + session_id: session_id.clone(), + model_name: model_name.clone(), + }); + } else { + error!("Failed to lock backend event sender"); + } + } + } + } + } + + /// Handle SandboxSelector events + fn on_sandbox_selector_event( + &mut self, + _selector: &Entity, + event: &SandboxSelectorEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + match event { + SandboxSelectorEvent::PolicyChanged { policy } => { + debug!("Sandbox policy changed"); + self.current_sandbox_policy = policy.clone(); + + if let Some(session_id) = &self.current_session_id { + let gpui = cx + .try_global::() + .expect("Failed to obtain Gpui global"); + if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { + let _ = sender.try_send(BackendEvent::ChangeSandboxPolicy { + session_id: session_id.clone(), + policy: policy.clone(), + }); + } else { + error!("Failed to lock backend event sender"); + } + } + } + } + } + #[allow(dead_code)] fn on_reset_click( &mut self, @@ -206,38 +310,6 @@ impl RootView { } } } - InputAreaEvent::ModelChanged { model_name } => { - debug!("Model selection changed to: {}", model_name); - - if let Some(session_id) = &self.current_session_id { - let gpui = cx - .try_global::() - .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchModel { - session_id: session_id.clone(), - model_name: model_name.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } - } - } - InputAreaEvent::SandboxChanged { policy } => { - if let Some(session_id) = &self.current_session_id { - let gpui = cx - .try_global::() - .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ChangeSandboxPolicy { - session_id: session_id.clone(), - policy: policy.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } - } - } } } @@ -264,9 +336,6 @@ impl RootView { session_id: session_id.clone(), }); } - ChatSidebarEvent::NewSessionRequested { name } => { - let _ = sender.try_send(BackendEvent::CreateNewSession { name: name.clone() }); - } } } else { error!("Failed to lock backend event sender"); @@ -656,23 +725,24 @@ impl Render for RootView { } } - // Ensure InputArea stays in sync with the current model - let selected_model = self.input_area.read(cx).current_model(); - if selected_model != current_model { + // Ensure model selector stays in sync with the current model + if self.current_model != current_model { debug!( "Current model changed from {:?} to {:?}", - selected_model, current_model + self.current_model, current_model ); - let model_to_set = current_model.clone(); - self.input_area.update(cx, |input_area, cx| { - input_area.set_current_model(model_to_set, window, cx); + self.current_model = current_model.clone(); + self.model_selector.update(cx, |selector, cx| { + selector.set_current_model(current_model.clone(), window, cx); }); } + // Ensure sandbox selector stays in sync with the current policy if let Some(policy) = current_sandbox_policy { - if self.input_area.read(cx).current_sandbox_policy() != policy { - self.input_area.update(cx, |input_area, cx| { - input_area.set_current_sandbox_policy(policy.clone(), window, cx); + if self.current_sandbox_policy != policy { + self.current_sandbox_policy = policy.clone(); + self.sandbox_selector.update(cx, |selector, cx| { + selector.set_policy(policy.clone(), window, cx); }); } } @@ -733,15 +803,16 @@ impl Render for RootView { .flex() .flex_row() .items_center() - .justify_start() + .justify_between() // Space between left and right sections // Left padding for macOS traffic lights (doubled for more space) .pl(px(86.)) + .pr(px(12.)) // Right padding for symmetry // Left side - controls .child( div() .flex() .items_center() - .gap_1() + .gap_2() // Chat sidebar toggle button .child( div() @@ -767,31 +838,67 @@ impl Render for RootView { cx.listener(Self::on_toggle_chat_sidebar), ), ) - // Theme toggle button + // "+ Chat" button with blue "+" and normal "Chat" text .child( div() - .size(px(28.)) + .h(px(28.)) + .px_2() .rounded_sm() .flex() .items_center() .justify_center() + .gap_1() .cursor_pointer() .hover(|s| s.bg(cx.theme().muted)) .child( - Icon::default() - .path(SharedString::from(if cx.theme().is_dark() { - "icons/theme_light.svg" - } else { - "icons/theme_dark.svg" - })) - .with_size(Size::Small) - .text_color(cx.theme().muted_foreground), + div() + .text_sm() + .font_medium() + .text_color(cx.theme().primary) // Blue color for "+" + .child("+"), + ) + .child( + div() + .text_sm() + .font_medium() + .text_color(cx.theme().muted_foreground) + .child("Chat"), ) .on_mouse_up( MouseButton::Left, - cx.listener(Self::on_toggle_theme), + cx.listener(Self::on_new_chat_click), ), - ), + ) + // Vertical separator + .child(div().h(px(20.)).w(px(1.)).bg(cx.theme().border)) + // Model selector + .child(self.model_selector.clone()) + // Sandbox/permissions selector + .child(self.sandbox_selector.clone()), + ) + // Right side - theme toggle (right-aligned) + .child( + div().flex().items_center().child( + div() + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from(if cx.theme().is_dark() { + "icons/theme_light.svg" + } else { + "icons/theme_dark.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_toggle_theme)), + ), ), ) // Main content area with chat sidebar and messages+input (2-column layout) From 31397ba836c052d215110f1cd95b2d81dcc1fb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 21:45:44 +0100 Subject: [PATCH 06/12] Improve plan banner --- crates/code_assistant/src/ui/gpui/file_icons.rs | 1 - crates/code_assistant/src/ui/gpui/plan_banner.rs | 13 ++++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/code_assistant/src/ui/gpui/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index 09fcc219..f138cc58 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -34,7 +34,6 @@ pub const WORKING_MEMORY: &str = "brain"; // brain.svg pub const SEND: &str = "send"; // send.svg pub const STOP: &str = "stop"; // circle_stop.svg 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 pub const TOOL_READ_FILES: &str = "search_code"; // search_code.svg diff --git a/crates/code_assistant/src/ui/gpui/plan_banner.rs b/crates/code_assistant/src/ui/gpui/plan_banner.rs index 5a1351c0..e3e953c0 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_1_5() .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() { From d699ec6db73d88c4e9f9d9edb9295393ee72e22d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 2 Dec 2025 21:46:50 +0100 Subject: [PATCH 07/12] Whoops --- crates/code_assistant/src/ui/gpui/plan_banner.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/code_assistant/src/ui/gpui/plan_banner.rs b/crates/code_assistant/src/ui/gpui/plan_banner.rs index e3e953c0..d1e2eff8 100644 --- a/crates/code_assistant/src/ui/gpui/plan_banner.rs +++ b/crates/code_assistant/src/ui/gpui/plan_banner.rs @@ -108,7 +108,7 @@ impl Render for PlanBanner { .border_t_1() .border_color(cx.theme().border) .px_4() - .py_1_5() + .py(px(6.)) .gap_2() .text_size(px(11.)) .line_height(px(15.)) From 485e07dbccdfdcd99b83ab7b9c12d3e2dbb62acd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 3 Dec 2025 12:37:07 +0100 Subject: [PATCH 08/12] Use gpui_compontent for buttons and icon buttons --- .../src/ui/gpui/chat_sidebar.rs | 40 +++-- .../code_assistant/src/ui/gpui/file_icons.rs | 2 - .../code_assistant/src/ui/gpui/input_area.rs | 118 ++++++--------- .../src/ui/gpui/model_selector.rs | 13 +- crates/code_assistant/src/ui/gpui/root.rs | 140 +++++++++--------- .../src/ui/gpui/sandbox_selector.rs | 13 +- 6 files changed, 140 insertions(+), 186 deletions(-) diff --git a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs index 18831ba2..3875c52e 100644 --- a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs +++ b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs @@ -1,4 +1,3 @@ -use super::file_icons; use crate::persistence::ChatMetadata; use crate::session::instance::SessionActivityState; use gpui::{ @@ -6,9 +5,9 @@ use gpui::{ InteractiveElement, MouseButton, MouseUpEvent, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; +use gpui_component::button::{Button, ButtonVariants}; 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; /// Events emitted by individual ChatListItem components @@ -207,24 +206,23 @@ impl Render for ChatListItem { ) .when(self.is_selected && self.is_hovered, |s| { s.child( - div() - .size(px(20.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().danger.opacity(0.1))) - .child(file_icons::render_icon( - &file_icons::get().get_type_icon("trash"), - 12.0, - cx.theme().danger, - "🗑", - )) - .on_mouse_up( - MouseButton::Left, - cx.listener(Self::on_session_delete), - ), + Button::new("delete-session") + .icon(Icon::default().path("icons/trash.svg")) + .ghost() + .compact() + .with_size(Size::XSmall) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_session_delete( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), ) }), ), diff --git a/crates/code_assistant/src/ui/gpui/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index f138cc58..835d12d0 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -31,8 +31,6 @@ 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 SEND: &str = "send"; // send.svg -pub const STOP: &str = "stop"; // circle_stop.svg pub const MESSAGE_BUBBLES: &str = "message_bubbles"; // message_bubbles.svg // Tool-specific icon mappings to actual SVG files diff --git a/crates/code_assistant/src/ui/gpui/input_area.rs b/crates/code_assistant/src/ui/gpui/input_area.rs index 29a07e24..442c7a7f 100644 --- a/crates/code_assistant/src/ui/gpui/input_area.rs +++ b/crates/code_assistant/src/ui/gpui/input_area.rs @@ -1,13 +1,13 @@ use super::attachment::{AttachmentEvent, AttachmentView}; -use super::file_icons; use crate::persistence::DraftAttachment; use base64::Engine; use gpui::{ - div, prelude::*, px, ClipboardEntry, Context, CursorStyle, Entity, EventEmitter, FocusHandle, - Focusable, MouseButton, MouseUpEvent, Render, Subscription, Window, + div, prelude::*, px, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, + MouseButton, MouseUpEvent, Render, Subscription, Window, }; +use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Input, InputEvent, InputState, Paste}; -use gpui_component::ActiveTheme; +use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, Size}; /// Events emitted by the InputArea component #[derive(Clone, Debug)] @@ -303,74 +303,48 @@ impl InputArea { .track_focus(&text_input_handle) .child(Input::new(&self.text_input).appearance(false)) }) - .children({ - let mut buttons = Vec::new(); - - // Show both send and cancel buttons - // Send button - enabled when input has content - let send_enabled = has_input_content; - let mut send_button = div() - .size(px(40.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor(if send_enabled { - CursorStyle::PointingHand - } else { - CursorStyle::OperationNotAllowed - }) - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::SEND), - 22.0, - if send_enabled { - cx.theme().primary - } else { - cx.theme().muted_foreground - }, - ">", - )); - - if send_enabled { - send_button = send_button - .hover(|s| s.bg(cx.theme().muted)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_submit_click)); - } - buttons.push(send_button); - - // Cancel button - always visible, but enabled/disabled based on agent state - let mut cancel_button = div() - .size(px(40.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor(if self.cancel_enabled { - CursorStyle::PointingHand - } else { - CursorStyle::OperationNotAllowed - }) - .child(file_icons::render_icon( - &file_icons::get().get_type_icon(file_icons::STOP), - 22.0, - if self.cancel_enabled { - cx.theme().danger - } else { - cx.theme().muted_foreground - }, - "⬜", - )); - - if self.cancel_enabled { - cancel_button = cancel_button - .hover(|s| s.bg(cx.theme().muted)) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_cancel_click)); - } - - buttons.push(cancel_button); - - buttons - }), + // Send button + .child( + Button::new("send-message") + .icon(Icon::default().path("icons/send.svg")) + .primary() + .compact() + .with_size(Size::Medium) + .disabled(!has_input_content) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_submit_click( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), + ) + // Cancel button + .child( + Button::new("cancel-agent") + .icon(Icon::default().path("icons/circle_stop.svg")) + .danger() + .compact() + .with_size(Size::Medium) + .disabled(!self.cancel_enabled) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_cancel_click( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), + ), ) } } diff --git a/crates/code_assistant/src/ui/gpui/model_selector.rs b/crates/code_assistant/src/ui/gpui/model_selector.rs index c41dc988..b0d4cd40 100644 --- a/crates/code_assistant/src/ui/gpui/model_selector.rs +++ b/crates/code_assistant/src/ui/gpui/model_selector.rs @@ -1,7 +1,7 @@ use gpui::{div, prelude::*, px, Context, Entity, EventEmitter, Focusable, Render, Window}; use gpui_component::{ select::{Select, SelectEvent, SelectItem, SelectState}, - ActiveTheme, Icon, Sizable, Size, + Icon, Sizable, Size, }; use llm::provider_config::ConfigurationSystem; use std::sync::Arc; @@ -202,18 +202,11 @@ impl Focusable for ModelSelector { } impl Render for ModelSelector { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - gpui::div().text_color(cx.theme().muted_foreground).child( + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::div().child( Select::new(&self.dropdown_state) .placeholder("Select Model") .with_size(Size::XSmall) - .appearance(false) - .icon( - Icon::default() - .path("icons/chevron_up_down.svg") - .with_size(Size::XSmall) - .text_color(cx.theme().muted_foreground), - ) .min_w(px(280.)), ) } diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index 259bf261..60fc2697 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -17,7 +17,10 @@ use gpui::{ Transformation, }; -use gpui_component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; +use gpui_component::{ + button::{Button, ButtonVariants}, + ActiveTheme, Icon, Sizable, Size, +}; use std::collections::HashMap; use tracing::{debug, error, trace, warn}; @@ -815,59 +818,50 @@ impl Render for RootView { .gap_2() // Chat sidebar toggle button .child( - div() - .size(px(28.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) - .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), - ), + Button::new("toggle-sidebar") + .icon(Icon::default().path(SharedString::from( + if self.chat_collapsed { + "icons/panel_left_open.svg" + } else { + "icons/panel_left_close.svg" + }, + ))) + .ghost() + .compact() + .with_size(Size::Small) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_toggle_chat_sidebar( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), ) - // "+ Chat" button with blue "+" and normal "Chat" text + // "+ Chat" button .child( - div() - .h(px(28.)) - .px_2() - .rounded_sm() - .flex() - .items_center() - .justify_center() - .gap_1() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) - .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().primary) // Blue color for "+" - .child("+"), - ) - .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().muted_foreground) - .child("Chat"), - ) - .on_mouse_up( - MouseButton::Left, - cx.listener(Self::on_new_chat_click), - ), + Button::new("new-chat") + .icon(Icon::default().path("icons/plus.svg")) + .label("Chat") + .ghost() + .compact() + .with_size(Size::Small) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_new_chat_click( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), ) // Vertical separator .child(div().h(px(20.)).w(px(1.)).bg(cx.theme().border)) @@ -879,25 +873,29 @@ impl Render for RootView { // Right side - theme toggle (right-aligned) .child( div().flex().items_center().child( - div() - .size(px(28.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) - .child( - Icon::default() - .path(SharedString::from(if cx.theme().is_dark() { - "icons/theme_light.svg" - } else { - "icons/theme_dark.svg" - })) - .with_size(Size::Small) - .text_color(cx.theme().muted_foreground), - ) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_toggle_theme)), + Button::new("toggle-theme") + .icon(Icon::default().path(SharedString::from( + if cx.theme().is_dark() { + "icons/theme_light.svg" + } else { + "icons/theme_dark.svg" + }, + ))) + .ghost() + .compact() + .with_size(Size::Small) + .on_click(cx.listener(|this, _event, window, cx| { + this.on_toggle_theme( + &MouseUpEvent { + button: MouseButton::Left, + position: gpui::Point::default(), + modifiers: gpui::Modifiers::default(), + click_count: 1, + }, + window, + cx, + ); + })), ), ), ) diff --git a/crates/code_assistant/src/ui/gpui/sandbox_selector.rs b/crates/code_assistant/src/ui/gpui/sandbox_selector.rs index 1565aa46..d2dcd8f1 100644 --- a/crates/code_assistant/src/ui/gpui/sandbox_selector.rs +++ b/crates/code_assistant/src/ui/gpui/sandbox_selector.rs @@ -1,7 +1,7 @@ use gpui::{div, prelude::*, px, Context, Entity, EventEmitter, Focusable, Render, Window}; use gpui_component::{ select::{Select, SelectEvent, SelectItem, SelectState}, - ActiveTheme, Icon, Sizable, Size, + Sizable, Size, }; use sandbox::SandboxPolicy; @@ -114,18 +114,11 @@ impl Focusable for SandboxSelector { } impl Render for SandboxSelector { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div().text_color(cx.theme().muted_foreground).child( + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child( Select::new(&self.dropdown_state) .placeholder("Sandbox Mode") .with_size(Size::XSmall) - .appearance(false) - .icon( - Icon::default() - .path("icons/chevron_up_down.svg") - .with_size(Size::XSmall) - .text_color(cx.theme().muted_foreground), - ) .min_w(px(130.)), ) } From 9933badc4a54e92819679adaa7870d997fcb8933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 3 Dec 2025 22:13:24 +0100 Subject: [PATCH 09/12] Revert "Use gpui_compontent for buttons and icon buttons" This reverts commit 485e07dbccdfdcd99b83ab7b9c12d3e2dbb62acd. --- .../src/ui/gpui/chat_sidebar.rs | 40 ++--- .../code_assistant/src/ui/gpui/file_icons.rs | 2 + .../code_assistant/src/ui/gpui/input_area.rs | 118 +++++++++------ .../src/ui/gpui/model_selector.rs | 13 +- crates/code_assistant/src/ui/gpui/root.rs | 140 +++++++++--------- .../src/ui/gpui/sandbox_selector.rs | 13 +- 6 files changed, 186 insertions(+), 140 deletions(-) diff --git a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs index 3875c52e..18831ba2 100644 --- a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs +++ b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs @@ -1,3 +1,4 @@ +use super::file_icons; use crate::persistence::ChatMetadata; use crate::session::instance::SessionActivityState; use gpui::{ @@ -5,9 +6,9 @@ use gpui::{ InteractiveElement, MouseButton, MouseUpEvent, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::scroll::ScrollbarAxis; -use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, Sizable, Size, StyledExt}; + +use gpui_component::{tooltip::Tooltip, ActiveTheme, Icon, StyledExt}; use std::time::SystemTime; /// Events emitted by individual ChatListItem components @@ -206,23 +207,24 @@ impl Render for ChatListItem { ) .when(self.is_selected && self.is_hovered, |s| { s.child( - Button::new("delete-session") - .icon(Icon::default().path("icons/trash.svg")) - .ghost() - .compact() - .with_size(Size::XSmall) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_session_delete( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), + div() + .size(px(20.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().danger.opacity(0.1))) + .child(file_icons::render_icon( + &file_icons::get().get_type_icon("trash"), + 12.0, + cx.theme().danger, + "🗑", + )) + .on_mouse_up( + MouseButton::Left, + cx.listener(Self::on_session_delete), + ), ) }), ), diff --git a/crates/code_assistant/src/ui/gpui/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index 835d12d0..f138cc58 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -31,6 +31,8 @@ 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 SEND: &str = "send"; // send.svg +pub const STOP: &str = "stop"; // circle_stop.svg pub const MESSAGE_BUBBLES: &str = "message_bubbles"; // message_bubbles.svg // Tool-specific icon mappings to actual SVG files diff --git a/crates/code_assistant/src/ui/gpui/input_area.rs b/crates/code_assistant/src/ui/gpui/input_area.rs index 442c7a7f..29a07e24 100644 --- a/crates/code_assistant/src/ui/gpui/input_area.rs +++ b/crates/code_assistant/src/ui/gpui/input_area.rs @@ -1,13 +1,13 @@ use super::attachment::{AttachmentEvent, AttachmentView}; +use super::file_icons; use crate::persistence::DraftAttachment; use base64::Engine; use gpui::{ - div, prelude::*, px, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, - MouseButton, MouseUpEvent, Render, Subscription, Window, + div, prelude::*, px, ClipboardEntry, Context, CursorStyle, Entity, EventEmitter, FocusHandle, + Focusable, MouseButton, MouseUpEvent, Render, Subscription, Window, }; -use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Input, InputEvent, InputState, Paste}; -use gpui_component::{ActiveTheme, Disableable, Icon, Sizable, Size}; +use gpui_component::ActiveTheme; /// Events emitted by the InputArea component #[derive(Clone, Debug)] @@ -303,48 +303,74 @@ impl InputArea { .track_focus(&text_input_handle) .child(Input::new(&self.text_input).appearance(false)) }) - // Send button - .child( - Button::new("send-message") - .icon(Icon::default().path("icons/send.svg")) - .primary() - .compact() - .with_size(Size::Medium) - .disabled(!has_input_content) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_submit_click( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), - ) - // Cancel button - .child( - Button::new("cancel-agent") - .icon(Icon::default().path("icons/circle_stop.svg")) - .danger() - .compact() - .with_size(Size::Medium) - .disabled(!self.cancel_enabled) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_cancel_click( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), - ), + .children({ + let mut buttons = Vec::new(); + + // Show both send and cancel buttons + // Send button - enabled when input has content + let send_enabled = has_input_content; + let mut send_button = div() + .size(px(40.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor(if send_enabled { + CursorStyle::PointingHand + } else { + CursorStyle::OperationNotAllowed + }) + .child(file_icons::render_icon( + &file_icons::get().get_type_icon(file_icons::SEND), + 22.0, + if send_enabled { + cx.theme().primary + } else { + cx.theme().muted_foreground + }, + ">", + )); + + if send_enabled { + send_button = send_button + .hover(|s| s.bg(cx.theme().muted)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_submit_click)); + } + buttons.push(send_button); + + // Cancel button - always visible, but enabled/disabled based on agent state + let mut cancel_button = div() + .size(px(40.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor(if self.cancel_enabled { + CursorStyle::PointingHand + } else { + CursorStyle::OperationNotAllowed + }) + .child(file_icons::render_icon( + &file_icons::get().get_type_icon(file_icons::STOP), + 22.0, + if self.cancel_enabled { + cx.theme().danger + } else { + cx.theme().muted_foreground + }, + "⬜", + )); + + if self.cancel_enabled { + cancel_button = cancel_button + .hover(|s| s.bg(cx.theme().muted)) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_cancel_click)); + } + + buttons.push(cancel_button); + + buttons + }), ) } } diff --git a/crates/code_assistant/src/ui/gpui/model_selector.rs b/crates/code_assistant/src/ui/gpui/model_selector.rs index b0d4cd40..c41dc988 100644 --- a/crates/code_assistant/src/ui/gpui/model_selector.rs +++ b/crates/code_assistant/src/ui/gpui/model_selector.rs @@ -1,7 +1,7 @@ use gpui::{div, prelude::*, px, Context, Entity, EventEmitter, Focusable, Render, Window}; use gpui_component::{ select::{Select, SelectEvent, SelectItem, SelectState}, - Icon, Sizable, Size, + ActiveTheme, Icon, Sizable, Size, }; use llm::provider_config::ConfigurationSystem; use std::sync::Arc; @@ -202,11 +202,18 @@ impl Focusable for ModelSelector { } impl Render for ModelSelector { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - gpui::div().child( + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + gpui::div().text_color(cx.theme().muted_foreground).child( Select::new(&self.dropdown_state) .placeholder("Select Model") .with_size(Size::XSmall) + .appearance(false) + .icon( + Icon::default() + .path("icons/chevron_up_down.svg") + .with_size(Size::XSmall) + .text_color(cx.theme().muted_foreground), + ) .min_w(px(280.)), ) } diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index 60fc2697..259bf261 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -17,10 +17,7 @@ use gpui::{ Transformation, }; -use gpui_component::{ - button::{Button, ButtonVariants}, - ActiveTheme, Icon, Sizable, Size, -}; +use gpui_component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; use std::collections::HashMap; use tracing::{debug, error, trace, warn}; @@ -818,50 +815,59 @@ impl Render for RootView { .gap_2() // Chat sidebar toggle button .child( - Button::new("toggle-sidebar") - .icon(Icon::default().path(SharedString::from( - if self.chat_collapsed { - "icons/panel_left_open.svg" - } else { - "icons/panel_left_close.svg" - }, - ))) - .ghost() - .compact() - .with_size(Size::Small) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_toggle_chat_sidebar( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), + div() + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .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), + ), ) - // "+ Chat" button + // "+ Chat" button with blue "+" and normal "Chat" text .child( - Button::new("new-chat") - .icon(Icon::default().path("icons/plus.svg")) - .label("Chat") - .ghost() - .compact() - .with_size(Size::Small) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_new_chat_click( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), + div() + .h(px(28.)) + .px_2() + .rounded_sm() + .flex() + .items_center() + .justify_center() + .gap_1() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + div() + .text_sm() + .font_medium() + .text_color(cx.theme().primary) // Blue color for "+" + .child("+"), + ) + .child( + div() + .text_sm() + .font_medium() + .text_color(cx.theme().muted_foreground) + .child("Chat"), + ) + .on_mouse_up( + MouseButton::Left, + cx.listener(Self::on_new_chat_click), + ), ) // Vertical separator .child(div().h(px(20.)).w(px(1.)).bg(cx.theme().border)) @@ -873,29 +879,25 @@ impl Render for RootView { // Right side - theme toggle (right-aligned) .child( div().flex().items_center().child( - Button::new("toggle-theme") - .icon(Icon::default().path(SharedString::from( - if cx.theme().is_dark() { - "icons/theme_light.svg" - } else { - "icons/theme_dark.svg" - }, - ))) - .ghost() - .compact() - .with_size(Size::Small) - .on_click(cx.listener(|this, _event, window, cx| { - this.on_toggle_theme( - &MouseUpEvent { - button: MouseButton::Left, - position: gpui::Point::default(), - modifiers: gpui::Modifiers::default(), - click_count: 1, - }, - window, - cx, - ); - })), + div() + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .cursor_pointer() + .hover(|s| s.bg(cx.theme().muted)) + .child( + Icon::default() + .path(SharedString::from(if cx.theme().is_dark() { + "icons/theme_light.svg" + } else { + "icons/theme_dark.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), + ) + .on_mouse_up(MouseButton::Left, cx.listener(Self::on_toggle_theme)), ), ), ) diff --git a/crates/code_assistant/src/ui/gpui/sandbox_selector.rs b/crates/code_assistant/src/ui/gpui/sandbox_selector.rs index d2dcd8f1..1565aa46 100644 --- a/crates/code_assistant/src/ui/gpui/sandbox_selector.rs +++ b/crates/code_assistant/src/ui/gpui/sandbox_selector.rs @@ -1,7 +1,7 @@ use gpui::{div, prelude::*, px, Context, Entity, EventEmitter, Focusable, Render, Window}; use gpui_component::{ select::{Select, SelectEvent, SelectItem, SelectState}, - Sizable, Size, + ActiveTheme, Icon, Sizable, Size, }; use sandbox::SandboxPolicy; @@ -114,11 +114,18 @@ impl Focusable for SandboxSelector { } impl Render for SandboxSelector { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div().child( + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + div().text_color(cx.theme().muted_foreground).child( Select::new(&self.dropdown_state) .placeholder("Sandbox Mode") .with_size(Size::XSmall) + .appearance(false) + .icon( + Icon::default() + .path("icons/chevron_up_down.svg") + .with_size(Size::XSmall) + .text_color(cx.theme().muted_foreground), + ) .min_w(px(130.)), ) } From 7b8c0d34d9c2813201a9625846a75d62e644f3df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Wed, 3 Dec 2025 22:15:00 +0100 Subject: [PATCH 10/12] Revert "Rearrange UI even more" This reverts commit f316592d12c7605f5bc17e708eaefe03e8913597. --- .../src/ui/gpui/chat_sidebar.rs | 92 ++++++- .../code_assistant/src/ui/gpui/input_area.rs | 115 +++++++++ crates/code_assistant/src/ui/gpui/root.rs | 227 +++++------------- 3 files changed, 264 insertions(+), 170 deletions(-) diff --git a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs index 18831ba2..d5ba0525 100644 --- a/crates/code_assistant/src/ui/gpui/chat_sidebar.rs +++ b/crates/code_assistant/src/ui/gpui/chat_sidebar.rs @@ -8,8 +8,9 @@ use gpui::{ }; 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; /// Events emitted by individual ChatListItem components #[derive(Clone, Debug)] @@ -27,6 +28,8 @@ pub enum ChatSidebarEvent { SessionSelected { session_id: String }, /// User requested deletion of a chat session SessionDeleteRequested { session_id: String }, + /// User requested creation of a new chat session + NewSessionRequested { name: Option }, } /// Individual chat list item component @@ -455,6 +458,22 @@ impl ChatSidebar { cx.notify(); } + fn on_new_chat_click( + &mut self, + _: &MouseUpEvent, + _window: &mut gpui::Window, + cx: &mut Context, + ) { + debug!("New chat button clicked"); + 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 }); + } + /// Handle events from ChatListItem components fn on_chat_list_item_event( &mut self, @@ -488,8 +507,37 @@ 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 - completely invisible (no element rendered) - div().id("collapsed-chat-sidebar").size_0() + // Collapsed view - narrow bar with new chat button + div() + .id("collapsed-chat-sidebar") + .flex_none() + .w(px(40.)) + .h_full() + .bg(cx.theme().sidebar) + .border_r_1() + .border_color(cx.theme().sidebar_border) + .flex() + .flex_col() + .items_center() + .gap_2() + .py_2() + .child( + div() + .size(px(28.)) + .rounded_sm() + .flex() + .items_center() + .justify_center() + .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 div() @@ -502,6 +550,44 @@ impl Render for ChatSidebar { .border_color(cx.theme().sidebar_border) .flex() .flex_col() + .child( + // Header with title and new chat button + div() + .flex_none() + .p_3() + .border_b_1() + .border_color(cx.theme().sidebar_border) + .flex() + .items_center() + .justify_between() + .child( + div() + .text_sm() + .font_medium() + .text_color(cx.theme().foreground) + .child("Chats"), + ) + .child( + div() + .size(px(24.)) + .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::PLUS), + 14.0, + cx.theme().muted_foreground, + "+", + )) + .on_mouse_up( + MouseButton::Left, + cx.listener(Self::on_new_chat_click), + ), + ), + ) .child( // Chat list area - outer container with padding div().flex_1().min_h(px(0.)).child( diff --git a/crates/code_assistant/src/ui/gpui/input_area.rs b/crates/code_assistant/src/ui/gpui/input_area.rs index 29a07e24..5717554b 100644 --- a/crates/code_assistant/src/ui/gpui/input_area.rs +++ b/crates/code_assistant/src/ui/gpui/input_area.rs @@ -1,5 +1,7 @@ use super::attachment::{AttachmentEvent, AttachmentView}; use super::file_icons; +use super::model_selector::{ModelSelector, ModelSelectorEvent}; +use super::sandbox_selector::{SandboxSelector, SandboxSelectorEvent}; use crate::persistence::DraftAttachment; use base64::Engine; use gpui::{ @@ -8,6 +10,7 @@ use gpui::{ }; use gpui_component::input::{Input, InputEvent, InputState, Paste}; use gpui_component::ActiveTheme; +use sandbox::SandboxPolicy; /// Events emitted by the InputArea component #[derive(Clone, Debug)] @@ -28,11 +31,19 @@ pub enum InputAreaEvent { CancelRequested, /// Clear draft requested (before clearing input) ClearDraftRequested, + /// Model selection changed + ModelChanged { model_name: String }, + /// Sandbox mode changed + SandboxChanged { policy: SandboxPolicy }, } /// Self-contained input area component that handles text input and attachments pub struct InputArea { text_input: Entity, + model_selector: Entity, + sandbox_selector: Entity, + current_model: Option, + current_sandbox_policy: SandboxPolicy, attachments: Vec, attachment_views: Vec>, focus_handle: FocusHandle, @@ -42,6 +53,8 @@ pub struct InputArea { cancel_enabled: bool, // Subscriptions _input_subscription: Subscription, + _model_selector_subscription: Subscription, + _sandbox_selector_subscription: Subscription, } impl InputArea { @@ -57,8 +70,22 @@ impl InputArea { // Subscribe to text input events let input_subscription = cx.subscribe_in(&text_input, window, Self::on_input_event); + // Create the model selector + let model_selector = cx.new(|cx| ModelSelector::new(window, cx)); + let sandbox_selector = cx.new(|cx| SandboxSelector::new(window, cx)); + + // Subscribe to model selector events + let model_selector_subscription = + cx.subscribe_in(&model_selector, window, Self::on_model_selector_event); + let sandbox_selector_subscription = + cx.subscribe_in(&sandbox_selector, window, Self::on_sandbox_selector_event); + Self { text_input, + model_selector, + sandbox_selector, + current_model: None, + current_sandbox_policy: SandboxPolicy::DangerFullAccess, attachments: Vec::new(), attachment_views: Vec::new(), focus_handle: cx.focus_handle(), @@ -66,6 +93,8 @@ impl InputArea { agent_is_running: false, cancel_enabled: false, _input_subscription: input_subscription, + _model_selector_subscription: model_selector_subscription, + _sandbox_selector_subscription: sandbox_selector_subscription, } } @@ -87,6 +116,47 @@ impl InputArea { self.rebuild_attachment_views(cx); } + /// Sync the dropdown with the current model selection + pub fn set_current_model( + &mut self, + model_name: Option, + window: &mut Window, + cx: &mut Context, + ) { + self.current_model = model_name.clone(); + self.model_selector.update(cx, |selector, cx| { + selector.set_current_model(model_name, window, cx) + }); + } + + /// Read the currently selected model name + pub fn current_model(&self) -> Option { + self.current_model.clone() + } + + pub fn set_current_sandbox_policy( + &mut self, + policy: SandboxPolicy, + window: &mut Window, + cx: &mut Context, + ) { + self.current_sandbox_policy = policy.clone(); + self.sandbox_selector.update(cx, |selector, cx| { + selector.set_policy(policy, window, cx); + }); + } + + pub fn current_sandbox_policy(&self) -> SandboxPolicy { + self.current_sandbox_policy.clone() + } + + /// Ensure the model list stays up to date + #[allow(dead_code)] + pub fn refresh_models(&mut self, window: &mut Window, cx: &mut Context) { + self.model_selector + .update(cx, |selector, cx| selector.refresh_models(window, cx)); + } + /// Clear the input content pub fn clear(&mut self, window: &mut Window, cx: &mut Context) { // Clear text input @@ -225,6 +295,41 @@ impl InputArea { } } + /// Handle model selector events + fn on_model_selector_event( + &mut self, + _model_selector: &Entity, + event: &ModelSelectorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + match event { + ModelSelectorEvent::ModelChanged { model_name } => { + self.current_model = Some(model_name.clone()); + cx.emit(InputAreaEvent::ModelChanged { + model_name: model_name.clone(), + }); + } + } + } + + fn on_sandbox_selector_event( + &mut self, + _selector: &Entity, + event: &SandboxSelectorEvent, + _window: &mut Window, + cx: &mut Context, + ) { + match event { + SandboxSelectorEvent::PolicyChanged { policy } => { + self.current_sandbox_policy = policy.clone(); + cx.emit(InputAreaEvent::SandboxChanged { + policy: policy.clone(), + }); + } + } + } + /// Handle submit button click fn on_submit_click(&mut self, _: &MouseUpEvent, window: &mut Window, cx: &mut Context) { let content = self.text_input.read(cx).value().to_string(); @@ -372,6 +477,16 @@ impl InputArea { buttons }), ) + // Model selector row + .child( + div() + .flex() + .gap_2() + .px_2() + .pb_2() + .child(div().flex_1().flex().child(self.model_selector.clone())) + .child(div().flex_1().flex().child(self.sandbox_selector.clone())), + ) } } diff --git a/crates/code_assistant/src/ui/gpui/root.rs b/crates/code_assistant/src/ui/gpui/root.rs index 259bf261..0166283f 100644 --- a/crates/code_assistant/src/ui/gpui/root.rs +++ b/crates/code_assistant/src/ui/gpui/root.rs @@ -3,9 +3,7 @@ use super::chat_sidebar::{ChatSidebar, ChatSidebarEvent}; use super::input_area::{InputArea, InputAreaEvent}; use super::messages::MessagesView; -use super::model_selector::{ModelSelector, ModelSelectorEvent}; use super::plan_banner; -use super::sandbox_selector::{SandboxSelector, SandboxSelectorEvent}; use super::theme; use super::BackendEvent; use super::{CloseWindow, Gpui, UiEventSender}; @@ -17,7 +15,7 @@ use gpui::{ Transformation, }; -use gpui_component::{ActiveTheme, Icon, Sizable, Size, StyledExt}; +use gpui_component::{ActiveTheme, Icon, Sizable, Size}; use std::collections::HashMap; use tracing::{debug, error, trace, warn}; @@ -27,10 +25,6 @@ pub struct RootView { chat_sidebar: Entity, auto_scroll_container: Entity>, plan_banner: Entity, - model_selector: Entity, - sandbox_selector: Entity, - current_model: Option, - current_sandbox_policy: sandbox::SandboxPolicy, recent_keystrokes: Vec, focus_handle: FocusHandle, // Chat sidebar state @@ -43,8 +37,6 @@ pub struct RootView { _input_area_subscription: Subscription, _plan_banner_subscription: Subscription, _chat_sidebar_subscription: Subscription, - _model_selector_subscription: Subscription, - _sandbox_selector_subscription: Subscription, } impl RootView { @@ -64,10 +56,6 @@ impl RootView { // Create the input area let input_area = cx.new(|cx| InputArea::new(window, cx)); - // Create model selector and sandbox selector for the title bar - let model_selector = cx.new(|cx| ModelSelector::new(window, cx)); - let sandbox_selector = cx.new(|cx| SandboxSelector::new(window, cx)); - // Subscribe to input area events let input_area_subscription = cx.subscribe_in(&input_area, window, Self::on_input_area_event); @@ -80,23 +68,11 @@ impl RootView { let plan_banner_subscription = cx.subscribe_in(&plan_banner, window, Self::on_plan_banner_event); - // Subscribe to model selector events - let model_selector_subscription = - cx.subscribe_in(&model_selector, window, Self::on_model_selector_event); - - // Subscribe to sandbox selector events - let sandbox_selector_subscription = - cx.subscribe_in(&sandbox_selector, window, Self::on_sandbox_selector_event); - let mut root_view = Self { input_area, chat_sidebar, auto_scroll_container, plan_banner, - model_selector, - sandbox_selector, - current_model: None, - current_sandbox_policy: sandbox::SandboxPolicy::DangerFullAccess, recent_keystrokes: vec![], focus_handle: cx.focus_handle(), chat_collapsed: false, // Chat sidebar is visible by default @@ -107,8 +83,6 @@ impl RootView { _input_area_subscription: input_area_subscription, _plan_banner_subscription: plan_banner_subscription, _chat_sidebar_subscription: chat_sidebar_subscription, - _model_selector_subscription: model_selector_subscription, - _sandbox_selector_subscription: sandbox_selector_subscription, }; // Request initial chat session list @@ -171,84 +145,6 @@ impl RootView { cx.notify(); } - fn on_new_chat_click( - &mut self, - _: &MouseUpEvent, - _window: &mut gpui::Window, - cx: &mut Context, - ) { - debug!("New chat button clicked from title bar"); - // Send event to create a new session - let gpui = cx - .try_global::() - .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::CreateNewSession { name: None }); - } else { - error!("Failed to lock backend event sender"); - } - } - - /// Handle ModelSelector events - fn on_model_selector_event( - &mut self, - _model_selector: &Entity, - event: &ModelSelectorEvent, - _window: &mut gpui::Window, - cx: &mut Context, - ) { - match event { - ModelSelectorEvent::ModelChanged { model_name } => { - debug!("Model selection changed to: {}", model_name); - self.current_model = Some(model_name.clone()); - - if let Some(session_id) = &self.current_session_id { - let gpui = cx - .try_global::() - .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::SwitchModel { - session_id: session_id.clone(), - model_name: model_name.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } - } - } - } - } - - /// Handle SandboxSelector events - fn on_sandbox_selector_event( - &mut self, - _selector: &Entity, - event: &SandboxSelectorEvent, - _window: &mut gpui::Window, - cx: &mut Context, - ) { - match event { - SandboxSelectorEvent::PolicyChanged { policy } => { - debug!("Sandbox policy changed"); - self.current_sandbox_policy = policy.clone(); - - if let Some(session_id) = &self.current_session_id { - let gpui = cx - .try_global::() - .expect("Failed to obtain Gpui global"); - if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { - let _ = sender.try_send(BackendEvent::ChangeSandboxPolicy { - session_id: session_id.clone(), - policy: policy.clone(), - }); - } else { - error!("Failed to lock backend event sender"); - } - } - } - } - } - #[allow(dead_code)] fn on_reset_click( &mut self, @@ -310,6 +206,38 @@ impl RootView { } } } + InputAreaEvent::ModelChanged { model_name } => { + debug!("Model selection changed to: {}", model_name); + + if let Some(session_id) = &self.current_session_id { + let gpui = cx + .try_global::() + .expect("Failed to obtain Gpui global"); + if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { + let _ = sender.try_send(BackendEvent::SwitchModel { + session_id: session_id.clone(), + model_name: model_name.clone(), + }); + } else { + error!("Failed to lock backend event sender"); + } + } + } + InputAreaEvent::SandboxChanged { policy } => { + if let Some(session_id) = &self.current_session_id { + let gpui = cx + .try_global::() + .expect("Failed to obtain Gpui global"); + if let Some(sender) = gpui.backend_event_sender.lock().unwrap().as_ref() { + let _ = sender.try_send(BackendEvent::ChangeSandboxPolicy { + session_id: session_id.clone(), + policy: policy.clone(), + }); + } else { + error!("Failed to lock backend event sender"); + } + } + } } } @@ -336,6 +264,9 @@ impl RootView { session_id: session_id.clone(), }); } + ChatSidebarEvent::NewSessionRequested { name } => { + let _ = sender.try_send(BackendEvent::CreateNewSession { name: name.clone() }); + } } } else { error!("Failed to lock backend event sender"); @@ -725,24 +656,23 @@ impl Render for RootView { } } - // Ensure model selector stays in sync with the current model - if self.current_model != current_model { + // Ensure InputArea stays in sync with the current model + let selected_model = self.input_area.read(cx).current_model(); + if selected_model != current_model { debug!( "Current model changed from {:?} to {:?}", - self.current_model, current_model + selected_model, current_model ); - self.current_model = current_model.clone(); - self.model_selector.update(cx, |selector, cx| { - selector.set_current_model(current_model.clone(), window, cx); + let model_to_set = current_model.clone(); + self.input_area.update(cx, |input_area, cx| { + input_area.set_current_model(model_to_set, window, cx); }); } - // Ensure sandbox selector stays in sync with the current policy if let Some(policy) = current_sandbox_policy { - if self.current_sandbox_policy != policy { - self.current_sandbox_policy = policy.clone(); - self.sandbox_selector.update(cx, |selector, cx| { - selector.set_policy(policy.clone(), window, cx); + if self.input_area.read(cx).current_sandbox_policy() != policy { + self.input_area.update(cx, |input_area, cx| { + input_area.set_current_sandbox_policy(policy.clone(), window, cx); }); } } @@ -803,16 +733,15 @@ impl Render for RootView { .flex() .flex_row() .items_center() - .justify_between() // Space between left and right sections + .justify_start() // Left padding for macOS traffic lights (doubled for more space) .pl(px(86.)) - .pr(px(12.)) // Right padding for symmetry // Left side - controls .child( div() .flex() .items_center() - .gap_2() + .gap_1() // Chat sidebar toggle button .child( div() @@ -838,67 +767,31 @@ impl Render for RootView { cx.listener(Self::on_toggle_chat_sidebar), ), ) - // "+ Chat" button with blue "+" and normal "Chat" text + // Theme toggle button .child( div() - .h(px(28.)) - .px_2() + .size(px(28.)) .rounded_sm() .flex() .items_center() .justify_center() - .gap_1() .cursor_pointer() .hover(|s| s.bg(cx.theme().muted)) .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().primary) // Blue color for "+" - .child("+"), - ) - .child( - div() - .text_sm() - .font_medium() - .text_color(cx.theme().muted_foreground) - .child("Chat"), + Icon::default() + .path(SharedString::from(if cx.theme().is_dark() { + "icons/theme_light.svg" + } else { + "icons/theme_dark.svg" + })) + .with_size(Size::Small) + .text_color(cx.theme().muted_foreground), ) .on_mouse_up( MouseButton::Left, - cx.listener(Self::on_new_chat_click), + cx.listener(Self::on_toggle_theme), ), - ) - // Vertical separator - .child(div().h(px(20.)).w(px(1.)).bg(cx.theme().border)) - // Model selector - .child(self.model_selector.clone()) - // Sandbox/permissions selector - .child(self.sandbox_selector.clone()), - ) - // Right side - theme toggle (right-aligned) - .child( - div().flex().items_center().child( - div() - .size(px(28.)) - .rounded_sm() - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hover(|s| s.bg(cx.theme().muted)) - .child( - Icon::default() - .path(SharedString::from(if cx.theme().is_dark() { - "icons/theme_light.svg" - } else { - "icons/theme_dark.svg" - })) - .with_size(Size::Small) - .text_color(cx.theme().muted_foreground), - ) - .on_mouse_up(MouseButton::Left, cx.listener(Self::on_toggle_theme)), - ), + ), ), ) // Main content area with chat sidebar and messages+input (2-column layout) From fa3fd626c5d50575d2a5b15d10b9df4c7c256790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Thu, 4 Dec 2025 07:13:30 +0100 Subject: [PATCH 11/12] Restore PLUS icon --- crates/code_assistant/src/ui/gpui/file_icons.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/code_assistant/src/ui/gpui/file_icons.rs b/crates/code_assistant/src/ui/gpui/file_icons.rs index f138cc58..09fcc219 100644 --- a/crates/code_assistant/src/ui/gpui/file_icons.rs +++ b/crates/code_assistant/src/ui/gpui/file_icons.rs @@ -34,6 +34,7 @@ pub const WORKING_MEMORY: &str = "brain"; // brain.svg pub const SEND: &str = "send"; // send.svg pub const STOP: &str = "stop"; // circle_stop.svg 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 pub const TOOL_READ_FILES: &str = "search_code"; // search_code.svg From 43776b2fe1ccbd3ccfb880038ce9640ab7246cda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20A=C3=9Fmus?= Date: Tue, 9 Dec 2025 07:59:17 +0100 Subject: [PATCH 12/12] ACP: Pass command line in "command", ignore args --- Cargo.lock | 7 --- crates/code_assistant/Cargo.toml | 1 - .../src/acp/terminal_executor.rs | 53 +++---------------- 3 files changed, 7 insertions(+), 54 deletions(-) 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/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")); - } -}