diff --git a/crates/code_assistant/Cargo.toml b/crates/code_assistant/Cargo.toml index 28d76c6f..f0e4857f 100644 --- a/crates/code_assistant/Cargo.toml +++ b/crates/code_assistant/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [features] default = ["document-conversion"] document-conversion = ["transmutation", "fs_explorer/document-conversion"] +# Enable the GPUI desktop UI. Requires Xcode / Metal on macOS. +# Without this flag the binary is TUI-only and compiles without Metal tools. +gpui-ui = ["dep:gpui", "dep:gpui_platform", "dep:gpui-component", "dep:terminal", "dep:terminal_view"] [dependencies] command_executor = { path = "../command_executor" } @@ -13,8 +16,8 @@ fs_explorer = { path = "../fs_explorer" } git = { path = "../git" } llm = { path = "../llm" } sandbox = { path = "../sandbox" } -terminal = { path = "../terminal" } -terminal_view = { path = "../terminal_view" } +terminal = { path = "../terminal", optional = true } +terminal_view = { path = "../terminal_view", optional = true } web = { path = "../web" } glob = "0.3" @@ -42,10 +45,10 @@ textwrap = "0.16" tui-markdown = "=0.3.6" derive_more = { version = "2", features = ["is_variant"] } -# GPUI related -gpui = "0.2.2" -gpui_platform = { version = "0.1", features = ["font-kit"] } -gpui-component = "0.5.2" +# GPUI related — only needed when the "gpui" feature is enabled +gpui = { version = "0.2.2", optional = true } +gpui_platform = { version = "0.1", features = ["font-kit"], optional = true } +gpui-component = { version = "0.5.2", optional = true } smallvec = "1.15" rust-embed = { version = "8.9", features = ["include-exclude"] } @@ -110,6 +113,5 @@ tokio-util = { version = "0.7", features = ["compat"] } core-text = "=21.0.0" [dev-dependencies] -gpui = { version = "0.2.2", features = ["test-support"] } axum = "0.7" bytes = "1.10" diff --git a/crates/code_assistant/src/app/mod.rs b/crates/code_assistant/src/app/mod.rs index 972a6ada..89c5178d 100644 --- a/crates/code_assistant/src/app/mod.rs +++ b/crates/code_assistant/src/app/mod.rs @@ -1,4 +1,5 @@ pub mod acp; +#[cfg(feature = "gpui-ui")] pub mod gpui; pub mod server; pub mod terminal; diff --git a/crates/code_assistant/src/cli.rs b/crates/code_assistant/src/cli.rs index 486a2f87..b740476c 100644 --- a/crates/code_assistant/src/cli.rs +++ b/crates/code_assistant/src/cli.rs @@ -179,11 +179,14 @@ impl Args { ); } - // Check persisted default model from settings - let settings = crate::ui::gpui::shared::settings::UiSettings::load(); - if let Some(ref default_model) = settings.default_model { - if config.get_model(default_model).is_some() { - return Ok(default_model.clone()); + // Check persisted default model from settings (only available in GPUI builds) + #[cfg(feature = "gpui-ui")] + { + let settings = crate::ui::gpui::shared::settings::UiSettings::load(); + if let Some(ref default_model) = settings.default_model { + if config.get_model(default_model).is_some() { + return Ok(default_model.clone()); + } } } diff --git a/crates/code_assistant/src/main.rs b/crates/code_assistant/src/main.rs index 3f70514a..7f08e9bf 100644 --- a/crates/code_assistant/src/main.rs +++ b/crates/code_assistant/src/main.rs @@ -82,6 +82,12 @@ async fn main() -> Result<()> { None => { if args.ui { // GPUI mode - use stderr to keep stdout clean + #[cfg(not(feature = "gpui-ui"))] + anyhow::bail!( + "This binary was compiled without the 'gpui' feature. \ + Rebuild with `cargo build --features gpui` to enable the desktop UI." + ); + #[cfg(feature = "gpui-ui")] setup_logging(args.verbose, false); } else { // Terminal UI mode - log to file to prevent UI interference @@ -96,6 +102,9 @@ async fn main() -> Result<()> { // In GUI mode, allow starting without a valid model config // (the settings screen will guide the user through setup). let model_name = if args.ui { + #[cfg(not(feature = "gpui-ui"))] + unreachable!("--ui requires the gpui feature"); + #[cfg(feature = "gpui-ui")] args.get_model_name().unwrap_or_default() } else { args.get_model_name()? @@ -116,6 +125,9 @@ async fn main() -> Result<()> { }; if args.ui { + #[cfg(not(feature = "gpui-ui"))] + unreachable!("--ui requires the gpui feature"); + #[cfg(feature = "gpui-ui")] app::gpui::run(config) } else { app::terminal::run(config).await diff --git a/crates/code_assistant/src/ui/backend.rs b/crates/code_assistant/src/ui/backend.rs index 11b02f97..3ef1f0fb 100644 --- a/crates/code_assistant/src/ui/backend.rs +++ b/crates/code_assistant/src/ui/backend.rs @@ -2,9 +2,11 @@ use crate::config::{save_project, DefaultProjectManager}; use crate::persistence::{ChatMetadata, DraftAttachment, SessionModelConfig}; use crate::session::SessionManager; use crate::types::Project; +#[cfg(feature = "gpui-ui")] use crate::ui::gpui::terminal::executor::GpuiTerminalCommandExecutor; use crate::ui::UserInterface; use crate::utils::content::content_blocks_from; +use command_executor::DefaultCommandExecutor; use llm::factory::create_llm_client_from_model; use llm::provider_config::ConfigurationSystem; use sandbox::SandboxPolicy; @@ -110,6 +112,21 @@ pub enum BackendEvent { session_id: String, }, + /// Clear the conversation context (messages) for a session. + /// + /// The session itself is kept alive; only the message history is wiped. + /// The UI transcript is cleared via `UiEvent::ClearMessages`. + ClearContext { + session_id: String, + }, + + /// Compact (summarise) conversation context for a session. + /// + /// Not yet implemented — emits an informational message instead. + CompactContext { + session_id: String, + }, + /// Incremental session refresh triggered by the file watcher. /// Compares the on-disk state with the in-memory state and emits only /// the delta (new messages appended by an external process). @@ -391,6 +408,31 @@ pub async fn handle_backend_events( handle_refresh_session(&multi_session_manager, &session_id, &ui).await } + BackendEvent::ClearContext { session_id } => { + let mut manager = multi_session_manager.lock().await; + if let Some(session) = manager.get_session_mut(&session_id) { + let chat = &mut session.session; + chat.message_nodes.clear(); + chat.active_path.clear(); + chat.next_node_id = 1; + chat.messages.clear(); + chat.plan = Default::default(); + } + let _ = ui.send_event(crate::ui::UiEvent::ClearMessages).await; + None + } + + BackendEvent::CompactContext { session_id: _ } => { + let _ = ui + .send_event(crate::ui::UiEvent::DisplayError { + message: + "Compact is not yet implemented. Use /clear to reset context." + .to_string(), + }) + .await; + None + } + BackendEvent::UpdateDefaultModel { model_name } => { debug!("Updating default model name to: {}", model_name); let mut manager = multi_session_manager.lock().await; @@ -649,7 +691,10 @@ async fn handle_send_user_message( // Start the agent (message already added) let result = { let project_manager = Box::new(DefaultProjectManager::new()); + #[cfg(feature = "gpui-ui")] let command_executor = Box::new(GpuiTerminalCommandExecutor::new(session_id.to_string())); + #[cfg(not(feature = "gpui-ui"))] + let command_executor = Box::new(DefaultCommandExecutor); let user_interface = ui.clone(); // Check if session has stored model config, otherwise use global config diff --git a/crates/code_assistant/src/ui/mod.rs b/crates/code_assistant/src/ui/mod.rs index d0a241ac..108ce6de 100644 --- a/crates/code_assistant/src/ui/mod.rs +++ b/crates/code_assistant/src/ui/mod.rs @@ -1,4 +1,5 @@ pub mod backend; +#[cfg(feature = "gpui-ui")] pub mod gpui; pub mod streaming; pub mod terminal; diff --git a/crates/code_assistant/src/ui/terminal/app.rs b/crates/code_assistant/src/ui/terminal/app.rs index 295e0b0b..1501e752 100644 --- a/crates/code_assistant/src/ui/terminal/app.rs +++ b/crates/code_assistant/src/ui/terminal/app.rs @@ -7,6 +7,7 @@ use crate::ui::backend::{ handle_backend_events, BackendEvent, BackendResponse, BackendRuntimeOptions, }; use crate::ui::terminal::{ + commands::all_commands, input::{InputManager, KeyEventResult}, renderer::ProductionTerminalRenderer, state::AppState, @@ -270,6 +271,136 @@ async fn event_loop( renderer_guard.set_plan_expanded(expanded); renderer_guard.set_overlay_active(overlay_active); } + KeyEventResult::ClearContext => { + let current_session_id = { + let state = app_state.lock().await; + state.current_session_id.clone() + }; + if let Some(session_id) = current_session_id { + let _ = backend_event_tx + .send(BackendEvent::ClearContext { session_id }) + .await; + } + } + KeyEventResult::CompactContext => { + let current_session_id = { + let state = app_state.lock().await; + state.current_session_id.clone() + }; + if let Some(session_id) = current_session_id { + let _ = backend_event_tx + .send(BackendEvent::CompactContext { session_id }) + .await; + } + } + KeyEventResult::UpdateAutocomplete { query } => { + match query { + None => { + // Current line no longer starts with '/'; close popup. + let mut state = app_state.lock().await; + state.close_autocomplete(); + input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); + } + Some(q) => { + let filtered: Vec<_> = all_commands() + .iter() + .filter(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }) + .collect(); + let mut state = app_state.lock().await; + if filtered.is_empty() { + state.close_autocomplete(); + input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); + } else { + // Clamp selected index to new list length. + let selected = state + .autocomplete_selected + .min(filtered.len().saturating_sub(1)); + state.open_autocomplete(q); + state.autocomplete_selected = selected; + input_manager.autocomplete_active = true; + drop(state); + let items: Vec<_> = filtered + .iter() + .map(|cmd| (cmd.name, cmd.description)) + .collect(); + renderer.lock().await.set_autocomplete(items, selected); + } + } + } + } + KeyEventResult::MoveAutocomplete(delta) => { + let (item_count, items) = { + let state = app_state.lock().await; + let q = state.autocomplete_query.clone(); + let filtered: Vec<_> = all_commands() + .iter() + .filter(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }) + .collect(); + let count = filtered.len(); + let items: Vec<_> = filtered + .iter() + .map(|cmd| (cmd.name, cmd.description)) + .collect(); + (count, items) + }; + let selected = { + let mut state = app_state.lock().await; + state.move_autocomplete_selection(delta, item_count); + state.autocomplete_selected + }; + renderer.lock().await.set_autocomplete(items, selected); + } + KeyEventResult::SelectAutocomplete => { + // Find the selected command and expand its name into the textarea. + let (selected_name, query) = { + let state = app_state.lock().await; + let q = state.autocomplete_query.clone(); + let filtered: Vec<_> = all_commands() + .iter() + .filter(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }) + .collect(); + let name = filtered + .get(state.autocomplete_selected) + .map(|cmd| cmd.name.to_string()); + (name, q) + }; + + if let Some(name) = selected_name { + // Replace "/" on the current line with "/ ". + // Compute the byte range: from start-of-line up to cursor. + let cursor = input_manager.textarea.cursor(); + let text = input_manager.textarea.text().to_string(); + let line_start = text[..cursor] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + // Replace exactly the portion "/" the user typed. + let typed_end = line_start + 1 + query.len(); // '/' + query + let replace_end = typed_end.min(cursor); + input_manager.textarea.replace_range( + line_start..replace_end, + &format!("/{name} "), + ); + let mut state = app_state.lock().await; + state.close_autocomplete(); + input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); + } + } } needs_redraw = true; } @@ -278,6 +409,45 @@ async fn event_loop( // normalize before processing. let pasted = pasted.replace('\r', "\n"); input_manager.handle_paste(pasted); + // Update autocomplete: pasting may introduce or clear a slash prefix. + match input_manager.slash_prefix() { + None => { + let mut state = app_state.lock().await; + state.close_autocomplete(); + input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); + } + Some(q) => { + let filtered: Vec<_> = all_commands() + .iter() + .filter(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }) + .collect(); + let mut state = app_state.lock().await; + if filtered.is_empty() { + state.close_autocomplete(); + input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); + } else { + let selected = state + .autocomplete_selected + .min(filtered.len().saturating_sub(1)); + state.open_autocomplete(q); + state.autocomplete_selected = selected; + input_manager.autocomplete_active = true; + drop(state); + let items: Vec<_> = filtered + .iter() + .map(|cmd| (cmd.name, cmd.description)) + .collect(); + renderer.lock().await.set_autocomplete(items, selected); + } + } + } needs_redraw = true; } Event::Resize(_, _) => { diff --git a/crates/code_assistant/src/ui/terminal/commands.rs b/crates/code_assistant/src/ui/terminal/commands.rs index b3e4cf0b..2e5ee48a 100644 --- a/crates/code_assistant/src/ui/terminal/commands.rs +++ b/crates/code_assistant/src/ui/terminal/commands.rs @@ -1,6 +1,58 @@ use anyhow::Result; use llm::provider_config::ConfigurationSystem; +/// Static descriptor for a slash command, used for autocomplete and help display. +pub struct SlashCommand { + pub name: &'static str, + pub aliases: &'static [&'static str], + pub description: &'static str, +} + +/// All registered slash commands. +/// +/// This slice is the single source of truth for command discovery. Every entry here +/// corresponds to a match arm in `CommandProcessor::process_command`. Keeping them +/// in sync is intentional: adding a command requires updating both places. +pub fn all_commands() -> &'static [SlashCommand] { + &[ + SlashCommand { + name: "help", + aliases: &["h"], + description: "Show available commands", + }, + SlashCommand { + name: "model", + aliases: &["m"], + description: "List available models or switch to one: /model ", + }, + SlashCommand { + name: "provider", + aliases: &["p"], + description: "List available LLM providers", + }, + SlashCommand { + name: "current", + aliases: &["c"], + description: "Show the currently active model", + }, + SlashCommand { + name: "plan", + aliases: &[], + description: "Toggle the plan view panel", + }, + SlashCommand { + name: "clear", + aliases: &[], + description: "Clear the conversation context", + }, + SlashCommand { + name: "compact", + aliases: &[], + description: "Summarize and compact the conversation context", + }, + ] +} + /// Result of processing a slash command #[derive(Debug, Clone)] pub enum CommandResult { @@ -20,6 +72,10 @@ pub enum CommandResult { InvalidCommand(String), /// Toggle plan rendering mode TogglePlan, + /// Clear conversation context + ClearContext, + /// Compact (summarise) conversation context + CompactContext, } /// Process slash commands in terminal UI @@ -52,6 +108,8 @@ impl CommandProcessor { "provider" | "p" => self.process_provider_command(&parts[1..]), "current" | "c" => CommandResult::ShowCurrentModel, "plan" => CommandResult::TogglePlan, + "clear" => CommandResult::ClearContext, + "compact" => CommandResult::CompactContext, _ => CommandResult::InvalidCommand(format!("Unknown command: /{}", parts[0])), } } @@ -84,20 +142,25 @@ impl CommandProcessor { } fn get_help_text(&self) -> String { - concat!( - "Available commands:\n", - "/help, /h - Show this help\n", - "/model, /m - List available models\n", - "/model - Switch to model\n", - "/provider, /p - List available providers\n", - "/current, /c - Show current model\n", - "/plan - Toggle plan view\n", - "\n", - "Examples:\n", - "/model Claude Sonnet 4.5\n", - "/model GPT-5", - ) - .to_string() + let mut text = String::from("Available commands:\n"); + for cmd in all_commands() { + if cmd.aliases.is_empty() { + text.push_str(&format!(" /{:<16} {}\n", cmd.name, cmd.description)); + } else { + let alias_list = cmd + .aliases + .iter() + .map(|a| format!("/{a}")) + .collect::>() + .join(", "); + text.push_str(&format!( + " /{}, {:<12} {}\n", + cmd.name, alias_list, cmd.description + )); + } + } + text.push_str("\nExamples:\n /model Claude Sonnet 4.5\n /model GPT-5"); + text } /// Get formatted list of available models diff --git a/crates/code_assistant/src/ui/terminal/input.rs b/crates/code_assistant/src/ui/terminal/input.rs index ddae34c9..c6983926 100644 --- a/crates/code_assistant/src/ui/terminal/input.rs +++ b/crates/code_assistant/src/ui/terminal/input.rs @@ -34,6 +34,21 @@ pub enum KeyEventResult { ShowCurrentModel, /// Toggle plan rendering mode TogglePlan, + /// Clear conversation context + ClearContext, + /// Compact (summarise) conversation context + CompactContext, + /// Update (or close) the autocomplete popup. + /// + /// `query` contains the text after `/` on the current input line. + /// `None` means the current line no longer starts with `/` — close the popup. + UpdateAutocomplete { + query: Option, + }, + /// Move the autocomplete selection up (-1) or down (+1). + MoveAutocomplete(i32), + /// Accept the currently highlighted autocomplete entry. + SelectAutocomplete, } /// Manages the input area using the custom TextArea widget @@ -48,6 +63,11 @@ pub struct InputManager { pending_pastes: Vec<(String, String)>, /// Counters for generating unique large-paste placeholders (keyed by char_count). large_paste_counters: HashMap, + /// Whether the autocomplete popup is currently visible. + /// + /// When `true`, Up/Down arrows navigate the popup instead of moving the + /// textarea cursor, and Tab/Enter accept the highlighted suggestion. + pub autocomplete_active: bool, } impl InputManager { @@ -60,6 +80,7 @@ impl InputManager { image_counter: 0, pending_pastes: Vec::new(), large_paste_counters: HashMap::new(), + autocomplete_active: false, } } @@ -84,18 +105,45 @@ impl InputManager { } KeyEventResult::Continue } + // Escape: close autocomplete popup if open, otherwise bubble up. KeyEvent { code: KeyCode::Esc, modifiers: KeyModifiers::NONE, .. - } => KeyEventResult::Escape, + } => { + if self.autocomplete_active { + KeyEventResult::UpdateAutocomplete { query: None } + } else { + KeyEventResult::Escape + } + } + // Up/Down: navigate the autocomplete list when the popup is open. + KeyEvent { + code: KeyCode::Up, + modifiers: KeyModifiers::NONE, + .. + } if self.autocomplete_active => KeyEventResult::MoveAutocomplete(-1), + KeyEvent { + code: KeyCode::Down, + modifiers: KeyModifiers::NONE, + .. + } if self.autocomplete_active => KeyEventResult::MoveAutocomplete(1), + // Tab: accept the highlighted autocomplete suggestion. + KeyEvent { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + .. + } if self.autocomplete_active => KeyEventResult::SelectAutocomplete, KeyEvent { code: KeyCode::Enter, modifiers: KeyModifiers::SHIFT, .. } => { self.textarea.insert_str("\n"); - KeyEventResult::Continue + // A newline on the current line means no slash-command on this line anymore. + KeyEventResult::UpdateAutocomplete { + query: self.slash_prefix(), + } } KeyEvent { code: KeyCode::Enter, @@ -128,6 +176,8 @@ impl InputManager { } CommandResult::ShowCurrentModel => KeyEventResult::ShowCurrentModel, CommandResult::TogglePlan => KeyEventResult::TogglePlan, + CommandResult::ClearContext => KeyEventResult::ClearContext, + CommandResult::CompactContext => KeyEventResult::CompactContext, CommandResult::InvalidCommand(error) => { KeyEventResult::ShowInfo(format!("Error: {error}")) } @@ -144,13 +194,30 @@ impl InputManager { } } _ => { - // Forward the key event directly to our custom TextArea + // Forward the key event directly to our custom TextArea, then check + // whether the current line now starts with a slash command. self.textarea.input(key_event); - KeyEventResult::Continue + KeyEventResult::UpdateAutocomplete { + query: self.slash_prefix(), + } } } } + /// Returns `Some(query)` when the current input line starts with `/`, where + /// `query` is the text after the `/`. Returns `None` otherwise. + /// + /// Used after every keystroke to decide whether to show/update/hide the + /// autocomplete popup. + pub fn slash_prefix(&self) -> Option { + let line = self.textarea.current_line(); + if line.starts_with('/') { + Some(line[1..].to_string()) + } else { + None + } + } + /// Handle a terminal paste event (from bracketed paste). pub fn handle_paste(&mut self, pasted: String) { let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n"); @@ -278,14 +345,20 @@ mod tests { // Test initial state assert_eq!(input_manager.textarea.text(), ""); - // Test character input + // Test character input: normal text returns UpdateAutocomplete with no query. let result = input_manager .handle_key_event(create_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); - assert!(matches!(result, KeyEventResult::Continue)); + assert!(matches!( + result, + KeyEventResult::UpdateAutocomplete { query: None } + )); let result = input_manager .handle_key_event(create_key_event(KeyCode::Char('i'), KeyModifiers::NONE)); - assert!(matches!(result, KeyEventResult::Continue)); + assert!(matches!( + result, + KeyEventResult::UpdateAutocomplete { query: None } + )); // Content should contain the typed characters let content = input_manager.textarea.text(); @@ -335,10 +408,10 @@ mod tests { input_manager.handle_key_event(create_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); input_manager.handle_key_event(create_key_event(KeyCode::Char('i'), KeyModifiers::NONE)); - // Shift+Enter should add newline without submitting + // Shift+Enter should add newline without submitting; returns UpdateAutocomplete. let result = input_manager.handle_key_event(create_key_event(KeyCode::Enter, KeyModifiers::SHIFT)); - assert!(matches!(result, KeyEventResult::Continue)); + assert!(matches!(result, KeyEventResult::UpdateAutocomplete { .. })); // Add more text input_manager.handle_key_event(create_key_event(KeyCode::Char('b'), KeyModifiers::NONE)); @@ -426,4 +499,83 @@ mod tests { assert!(input_manager.attachments.is_empty()); assert_eq!(input_manager.image_counter, 0); } + + #[test] + fn test_slash_prefix_detected() { + let mut input_manager = InputManager::new(); + + // Typing '/' should emit UpdateAutocomplete with an empty query string. + let result = input_manager + .handle_key_event(create_key_event(KeyCode::Char('/'), KeyModifiers::NONE)); + assert!( + matches!(result, KeyEventResult::UpdateAutocomplete { query: Some(ref q) } if q.is_empty()), + "Expected UpdateAutocomplete with empty query, got {result:?}" + ); + + // Typing 'm' after '/' should emit query "m". + let result = input_manager + .handle_key_event(create_key_event(KeyCode::Char('m'), KeyModifiers::NONE)); + assert!( + matches!(result, KeyEventResult::UpdateAutocomplete { query: Some(ref q) } if q == "m"), + "Expected query 'm', got {result:?}" + ); + } + + #[test] + fn test_slash_prefix_absent_for_normal_text() { + let mut input_manager = InputManager::new(); + let result = input_manager + .handle_key_event(create_key_event(KeyCode::Char('h'), KeyModifiers::NONE)); + assert!( + matches!(result, KeyEventResult::UpdateAutocomplete { query: None }), + "Expected no autocomplete for normal text, got {result:?}" + ); + } + + #[test] + fn test_escape_closes_autocomplete_when_active() { + let mut input_manager = InputManager::new(); + input_manager.autocomplete_active = true; + + let result = + input_manager.handle_key_event(create_key_event(KeyCode::Esc, KeyModifiers::NONE)); + // When popup is open, Escape should close it, not propagate as Escape. + assert!( + matches!(result, KeyEventResult::UpdateAutocomplete { query: None }), + "Expected autocomplete close on Escape, got {result:?}" + ); + } + + #[test] + fn test_arrow_keys_navigate_autocomplete_when_active() { + let mut input_manager = InputManager::new(); + input_manager.autocomplete_active = true; + + let down = input_manager + .handle_key_event(create_key_event(KeyCode::Down, KeyModifiers::NONE)); + assert!( + matches!(down, KeyEventResult::MoveAutocomplete(1)), + "Expected MoveAutocomplete(1), got {down:?}" + ); + + let up = input_manager + .handle_key_event(create_key_event(KeyCode::Up, KeyModifiers::NONE)); + assert!( + matches!(up, KeyEventResult::MoveAutocomplete(-1)), + "Expected MoveAutocomplete(-1), got {up:?}" + ); + } + + #[test] + fn test_tab_selects_autocomplete_when_active() { + let mut input_manager = InputManager::new(); + input_manager.autocomplete_active = true; + + let result = input_manager + .handle_key_event(create_key_event(KeyCode::Tab, KeyModifiers::NONE)); + assert!( + matches!(result, KeyEventResult::SelectAutocomplete), + "Expected SelectAutocomplete on Tab, got {result:?}" + ); + } } diff --git a/crates/code_assistant/src/ui/terminal/renderer.rs b/crates/code_assistant/src/ui/terminal/renderer.rs index fcd2239e..b103d782 100644 --- a/crates/code_assistant/src/ui/terminal/renderer.rs +++ b/crates/code_assistant/src/ui/terminal/renderer.rs @@ -111,6 +111,11 @@ pub struct TerminalRenderer { needs_paragraph_break_after_hidden_tool: bool, /// Last known terminal width (updated in prepare(), used for history rendering). last_known_width: u16, + /// Filtered list of (name, description) pairs to show in the autocomplete popup. + /// Empty when the popup is hidden. + autocomplete_items: Vec<(&'static str, &'static str)>, + /// Index of the highlighted row in `autocomplete_items`. + autocomplete_selected: usize, } /// Tracks the last block type for paragraph breaks after hidden tools @@ -144,6 +149,8 @@ impl TerminalRenderer { last_block_type_for_hidden_tool: None, needs_paragraph_break_after_hidden_tool: false, last_known_width: 80, + autocomplete_items: Vec::new(), + autocomplete_selected: 0, }) } @@ -298,6 +305,25 @@ impl TerminalRenderer { self.overlay_active = active; } + /// Update the autocomplete popup contents. + /// + /// `items` is the pre-filtered list of (name, description) pairs to display. + /// `selected` is the index of the highlighted row. + pub fn set_autocomplete( + &mut self, + items: Vec<(&'static str, &'static str)>, + selected: usize, + ) { + self.autocomplete_items = items; + self.autocomplete_selected = selected; + } + + /// Hide the autocomplete popup. + pub fn clear_autocomplete(&mut self) { + self.autocomplete_items.clear(); + self.autocomplete_selected = 0; + } + /// Append text to the last block in the current message #[cfg_attr(not(test), allow(dead_code))] pub fn append_to_live_block(&mut self, text: &str) { @@ -933,6 +959,16 @@ impl TerminalRenderer { // Render input area (block + textarea) self.composer.render(f, input_area, textarea); + + // Render autocomplete popup on top of the input area if items are present. + if !self.autocomplete_items.is_empty() { + Self::render_autocomplete_popup( + f, + input_area, + &self.autocomplete_items, + self.autocomplete_selected, + ); + } } /// Render a message to the scratch buffer, updating cursor_y @@ -1190,6 +1226,108 @@ impl TerminalRenderer { format!("Error: {message} (Press Esc to dismiss)") } + /// Render the slash-command autocomplete popup above the input area. + /// + /// The popup is a borderless list drawn over `input_area`. Each row shows + /// ` /name — description `; the highlighted row is drawn with a dark + /// background so it stands out without being distracting. + /// + /// Layout: the popup height is `min(items.len(), MAX_POPUP_ROWS)`. It is + /// anchored to the top-left of `input_area` and extends upward (i.e. it + /// sits between the scroll-back content and the composer). If `input_area` + /// is not tall enough to fit the popup it is clamped to the available space. + fn render_autocomplete_popup( + f: &mut custom_terminal::Frame, + input_area: Rect, + items: &[(&'static str, &'static str)], + selected: usize, + ) { + const MAX_POPUP_ROWS: u16 = 8; + const H_PADDING: u16 = 1; // spaces on each side of the text + + if items.is_empty() || input_area.height == 0 { + return; + } + + // How wide should each row be? + let max_text_width = items + .iter() + .map(|(name, desc)| { + // " /name — description " + 2 + 1 + name.len() + 2 + 3 + 2 + desc.len() + 2 + }) + .max() + .unwrap_or(20) as u16; + let popup_width = max_text_width + .max(20) + .min(input_area.width.saturating_sub(H_PADDING * 2)); + + let popup_height = (items.len() as u16).min(MAX_POPUP_ROWS); + + // Position the popup directly above the input area, left-aligned with + // a small horizontal indent so it visually aligns with the "› " prefix. + let popup_x = input_area.x + 2; // indent under the "› " prefix + let popup_y = input_area.y.saturating_sub(popup_height); + + // Clamp to screen bounds. + let popup_x = popup_x.min( + f.area() + .width + .saturating_sub(popup_width), + ); + + let popup_area = Rect { + x: popup_x, + y: popup_y, + width: popup_width, + height: popup_height, + }; + + // Determine which items are visible (top `MAX_POPUP_ROWS` items starting + // from the window that keeps `selected` in view). + let window_start = if selected >= MAX_POPUP_ROWS as usize { + selected + 1 - MAX_POPUP_ROWS as usize + } else { + 0 + }; + let visible: Vec<_> = items + .iter() + .enumerate() + .skip(window_start) + .take(MAX_POPUP_ROWS as usize) + .collect(); + + for (row_idx, (item_idx, (name, desc))) in visible.iter().enumerate() { + let row_area = Rect { + x: popup_area.x, + y: popup_area.y + row_idx as u16, + width: popup_area.width, + height: 1, + }; + + let is_selected = *item_idx == selected; + let (bg, fg, name_fg) = if is_selected { + (Color::DarkGray, Color::White, Color::Cyan) + } else { + (Color::Reset, Color::DarkGray, Color::Gray) + }; + + // Fill the whole row background first. + let bg_style = Style::default().bg(bg); + let fill = Line::raw(" ".repeat(popup_area.width as usize)); + f.render_widget(Paragraph::new(fill).style(bg_style), row_area); + + // Render " /name — description" over the background. + let line = Line::from(vec![ + Span::styled(" ", Style::default().bg(bg)), + Span::styled(format!("/{name}"), Style::default().fg(name_fg).bg(bg)), + Span::styled(" — ", Style::default().fg(fg).bg(bg)), + Span::styled(*desc, Style::default().fg(fg).bg(bg)), + ]); + f.render_widget(Paragraph::new(line), row_area); + } + } + /// Set an error message to display pub fn set_error(&mut self, error_message: String) { self.current_error = Some(error_message); diff --git a/crates/code_assistant/src/ui/terminal/state.rs b/crates/code_assistant/src/ui/terminal/state.rs index 578e76c8..95fee0e0 100644 --- a/crates/code_assistant/src/ui/terminal/state.rs +++ b/crates/code_assistant/src/ui/terminal/state.rs @@ -24,6 +24,12 @@ pub struct AppState { pub current_model: Option, pub info_message: Option, pub current_sandbox_policy: Option, + /// Whether the slash-command autocomplete popup is currently visible. + pub autocomplete_active: bool, + /// The text the user has typed after the leading `/` on the current line. + pub autocomplete_query: String, + /// Index of the currently highlighted entry in the filtered command list. + pub autocomplete_selected: usize, } impl AppState { @@ -42,6 +48,9 @@ impl AppState { current_model: None, info_message: None, current_sandbox_policy: None, + autocomplete_active: false, + autocomplete_query: String::new(), + autocomplete_selected: 0, } } @@ -105,4 +114,34 @@ impl AppState { pub fn is_overlay_active(&self) -> bool { !matches!(self.overlay_state, OverlayState::None) } + + /// Open (or update) the autocomplete popup with a new query string. + /// + /// `query` is the text after the leading `/` on the current input line. + /// Calling this resets the selection to the first item. + pub fn open_autocomplete(&mut self, query: String) { + self.autocomplete_active = true; + self.autocomplete_query = query; + self.autocomplete_selected = 0; + } + + /// Close the autocomplete popup and reset all related state. + pub fn close_autocomplete(&mut self) { + self.autocomplete_active = false; + self.autocomplete_query.clear(); + self.autocomplete_selected = 0; + } + + /// Move the selection by `delta` rows (positive = down, negative = up), + /// wrapping around within `[0, item_count)`. + /// + /// Does nothing when `item_count` is zero. + pub fn move_autocomplete_selection(&mut self, delta: i32, item_count: usize) { + if item_count == 0 { + return; + } + let current = self.autocomplete_selected as i32; + let next = (current + delta).rem_euclid(item_count as i32) as usize; + self.autocomplete_selected = next; + } } diff --git a/crates/code_assistant/src/ui/terminal/textarea.rs b/crates/code_assistant/src/ui/terminal/textarea.rs index ea83eeab..a55b5889 100644 --- a/crates/code_assistant/src/ui/terminal/textarea.rs +++ b/crates/code_assistant/src/ui/terminal/textarea.rs @@ -181,6 +181,20 @@ impl TextArea { Some((area.x + col, area.y + i as u16)) } + /// Returns the text of the logical line the cursor is currently on. + /// + /// A "logical line" is the text between two `\n` characters (or the start/end + /// of the buffer). This is the unprocessed content before word-wrap is applied, + /// so it may be longer than a single rendered row on screen. + /// + /// Used by the slash-command autocomplete to check whether the user is typing + /// a `/` command without having to re-parse the full textarea text. + pub fn current_line(&self) -> &str { + let bol = self.beginning_of_current_line(); + let eol = self.end_of_current_line(); + &self.text[bol..eol] + } + pub fn input(&mut self, event: KeyEvent) { match event { // C0 control character fallbacks (terminals that don't report CONTROL modifier) @@ -1139,4 +1153,34 @@ mod tests { assert_eq!(ta.elements.len(), 0); assert_eq!(ta.text(), ""); } + + #[test] + fn test_current_line_single_line() { + let mut ta = TextArea::new(); + ta.insert_str("hello world"); + assert_eq!(ta.current_line(), "hello world"); + } + + #[test] + fn test_current_line_multiline_first() { + let mut ta = TextArea::new(); + ta.insert_str("first\nsecond\nthird"); + // Move cursor to the beginning of the first line + ta.set_cursor(0); + assert_eq!(ta.current_line(), "first"); + } + + #[test] + fn test_current_line_multiline_last() { + let mut ta = TextArea::new(); + ta.insert_str("first\nsecond\nthird"); + // Cursor is at the end (after "third") + assert_eq!(ta.current_line(), "third"); + } + + #[test] + fn test_current_line_empty() { + let ta = TextArea::new(); + assert_eq!(ta.current_line(), ""); + } } diff --git a/crates/code_assistant/src/ui/ui_events.rs b/crates/code_assistant/src/ui/ui_events.rs index a44c2eed..15751401 100644 --- a/crates/code_assistant/src/ui/ui_events.rs +++ b/crates/code_assistant/src/ui/ui_events.rs @@ -6,6 +6,17 @@ use crate::ui::{DisplayFragment, ToolStatus}; use sandbox::SandboxPolicy; use std::path::PathBuf; +// When the gpui-ui feature is enabled, StyledLine comes from the `terminal` crate +// (which captures ANSI-coloured output from the interactive terminal tool). +// Without gpui-ui the field is always `None`, so we use a zero-sized stub that +// satisfies the type checker without pulling in the Metal dependency chain. +#[cfg(feature = "gpui-ui")] +pub use terminal::StyledLine; + +#[cfg(not(feature = "gpui-ui"))] +#[derive(Debug, Clone)] +pub struct StyledLine; + /// Role of a message in the conversation. #[derive(Debug, Clone, PartialEq)] pub enum MessageRole { @@ -34,7 +45,7 @@ pub struct ToolResultData { pub message: Option, pub output: Option, /// Styled terminal output with ANSI color information preserved. - pub styled_output: Option>, + pub styled_output: Option>, /// Duration of the tool execution in seconds, computed from persisted ContentBlock timestamps. pub duration_seconds: Option, /// Image data from tools that produce visual output (e.g. view_images). @@ -79,7 +90,7 @@ pub enum UiEvent { message: Option, output: Option, /// Styled terminal output with ANSI color information preserved. - styled_output: Option>, + styled_output: Option>, /// Execution duration in seconds, set from ContentBlock timestamps on completion. duration_seconds: Option, /// Image data from tools that produce visual output (e.g. view_images).