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..45f0057c 100644 --- a/crates/code_assistant/src/ui/terminal/app.rs +++ b/crates/code_assistant/src/ui/terminal/app.rs @@ -270,6 +270,28 @@ 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; + } + } } needs_redraw = true; } 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..167e5615 100644 --- a/crates/code_assistant/src/ui/terminal/input.rs +++ b/crates/code_assistant/src/ui/terminal/input.rs @@ -34,6 +34,10 @@ pub enum KeyEventResult { ShowCurrentModel, /// Toggle plan rendering mode TogglePlan, + /// Clear conversation context + ClearContext, + /// Compact (summarise) conversation context + CompactContext, } /// Manages the input area using the custom TextArea widget @@ -128,6 +132,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}")) } 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).