From 437551e0e3a091a45cc1c752481606319202fdbe Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 11 Jun 2026 10:23:11 +0200 Subject: [PATCH 1/4] build: make GPUI an optional feature to allow TUI-only builds The GPUI desktop UI depends on Metal shader compilation, which requires Xcode developer tools to be installed (xcrun must find the `metal` binary). On machines without Xcode this hard-blocked every `cargo build`, even when only the terminal TUI was needed. Introduce a `gpui-ui` Cargo feature that gates all GPUI-related code: - `Cargo.toml`: `gpui`, `gpui_platform`, and `gpui-component` are now optional dependencies, activated only by `--features gpui-ui`. The gpui entry in `[dev-dependencies]` is removed (it was unused in the unit-test suite). - `src/app/mod.rs`, `src/ui/mod.rs`: `pub mod gpui` gated behind `#[cfg(feature = "gpui-ui")]`. - `src/ui/backend.rs`: The `GpuiTerminalCommandExecutor` import and its single usage site are gated; the TUI-only path falls back to `DefaultCommandExecutor` (which `GpuiTerminalCommandExecutor` itself delegates to internally). - `src/main.rs`: The `--ui` code paths (logging setup, model resolution, `app::gpui::run()`) are gated. Passing `--ui` without the feature produces a clear error message rather than a linker crash. - `src/cli.rs`: The `UiSettings::load()` call that reads the GPUI- persisted default model is gated; TUI-only builds fall through to the alphabetical-first-model fallback. Build commands: # TUI only (no Xcode required): cargo build --no-default-features # With GPUI desktop UI (requires Xcode / Metal): cargo build --features gpui-ui --- crates/code_assistant/Cargo.toml | 16 +++++++++------- crates/code_assistant/src/app/mod.rs | 1 + crates/code_assistant/src/cli.rs | 13 ++++++++----- crates/code_assistant/src/main.rs | 12 ++++++++++++ crates/code_assistant/src/ui/backend.rs | 5 +++++ crates/code_assistant/src/ui/mod.rs | 1 + crates/code_assistant/src/ui/ui_events.rs | 15 +++++++++++++-- 7 files changed, 49 insertions(+), 14 deletions(-) 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..ddc61dd8 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; @@ -649,7 +651,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/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). From 4b4a221a9eb46c3d86b527d3d7b33027f8437364 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 10 Jun 2026 18:31:41 +0200 Subject: [PATCH 2/4] feat: add SlashCommand descriptor registry to commands.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a `SlashCommand` struct (name, aliases, description) and an `all_commands()` function that returns a static slice of all registered commands. This gives the rest of the codebase — in particular the upcoming autocomplete popup — a single, authoritative place to discover what slash commands exist, without needing to duplicate the match arms in `process_command`. The help text is now derived from the registry instead of being a hard- coded string, so adding a new command in one place is sufficient. No behavior change: all existing commands keep the same names, aliases, and runtime behaviour. --- .../src/ui/terminal/commands.rs | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/crates/code_assistant/src/ui/terminal/commands.rs b/crates/code_assistant/src/ui/terminal/commands.rs index b3e4cf0b..25e062c1 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 { @@ -84,20 +136,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 From 73deb60df85248365490cd7ba1492728873bf2bd Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 10 Jun 2026 18:34:24 +0200 Subject: [PATCH 3/4] feat: add /clear and /compact slash commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new slash commands to the terminal TUI: - `/clear` — wipes the conversation message history (message nodes, active path, plan) for the current session, then clears the UI transcript. The session itself stays alive so the user can continue without re-starting. - `/compact` — placeholder that shows an informational error until a proper summarisation implementation lands. The data path is: CommandProcessor (commands.rs) → KeyEventResult::{ClearContext,CompactContext} (input.rs) → BackendEvent::{ClearContext,CompactContext} (app.rs) → handler in handle_backend_events (backend.rs) → UiEvent::ClearMessages / UiEvent::DisplayError (terminal UI) --- crates/code_assistant/src/ui/backend.rs | 40 +++++++++++++++++++ crates/code_assistant/src/ui/terminal/app.rs | 22 ++++++++++ .../src/ui/terminal/commands.rs | 6 +++ .../code_assistant/src/ui/terminal/input.rs | 6 +++ 4 files changed, 74 insertions(+) diff --git a/crates/code_assistant/src/ui/backend.rs b/crates/code_assistant/src/ui/backend.rs index ddc61dd8..3ef1f0fb 100644 --- a/crates/code_assistant/src/ui/backend.rs +++ b/crates/code_assistant/src/ui/backend.rs @@ -112,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). @@ -393,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; 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 25e062c1..2e5ee48a 100644 --- a/crates/code_assistant/src/ui/terminal/commands.rs +++ b/crates/code_assistant/src/ui/terminal/commands.rs @@ -72,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 @@ -104,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])), } } 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}")) } From c81a4707f2ef99a7a56d1f4a6080b719f99bc754 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 10 Jun 2026 18:35:41 +0200 Subject: [PATCH 4/4] feat: expose current_line() on TextArea Add a public `current_line() -> &str` method that returns the text of the logical line the cursor is currently on (i.e. the content between the two surrounding newlines, or the start/end of the buffer). This is a thin wrapper over the two existing private helpers `beginning_of_current_line()` and `end_of_current_line()`; no logic is duplicated and no behavior changes. The method is the entry point for the upcoming slash-command autocomplete (PR 4), which needs to inspect what the user has typed on the current line without re-parsing the entire textarea buffer. Four unit tests cover: single-line content, cursor on first/last line of multi-line content, and the empty-textarea edge case. --- .../src/ui/terminal/textarea.rs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) 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(), ""); + } }