From 437551e0e3a091a45cc1c752481606319202fdbe Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Thu, 11 Jun 2026 10:23:11 +0200 Subject: [PATCH 1/6] 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/6] 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/6] 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/6] 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(), ""); + } } From 575a0c5714cddd888197ba6e3738a0f6ff4783b1 Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 10 Jun 2026 18:40:07 +0200 Subject: [PATCH 5/6] feat: wire autocomplete state and input handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the data-flow layer for the slash-command autocomplete popup. No visual rendering yet — that lands in the next PR. ## state.rs - Three new fields on `AppState`: `autocomplete_active`, `autocomplete_query` (text after the `/`), `autocomplete_selected` (highlighted row index). - Three new helpers: `open_autocomplete(query)`, `close_autocomplete()`, `move_autocomplete_selection(delta, count)` (wraps around with rem_euclid). ## input.rs - `autocomplete_active: bool` added to `InputManager` so Up/Down/Tab can be intercepted when the popup is open without checking `AppState` from inside the input layer. - New `KeyEventResult` variants: - `UpdateAutocomplete { query: Option }` — emitted after every keystroke; `None` means close the popup, `Some(q)` means open/update. - `MoveAutocomplete(i32)` — Up/Down while popup open. - `SelectAutocomplete` — Tab (or Enter via popup) while popup open. - The `_ =>` fallback arm now returns `UpdateAutocomplete` instead of `Continue`; the rest of the code-paths (Shift+Enter) do likewise. - Escape while `autocomplete_active` closes the popup instead of bubbling. - `slash_prefix()` is now public so `app.rs` can call it after paste events. - Tests updated for the new return types; new tests cover slash detection, arrow-key interception, Tab selection, and Escape dismissal. ## app.rs - Imports `all_commands()` for filtering. - Three new match arms in the event loop: - `UpdateAutocomplete` — filters `all_commands()` by prefix, calls `state.open_autocomplete` / `state.close_autocomplete`, syncs `input_manager.autocomplete_active`. - `MoveAutocomplete` — delegates to `state.move_autocomplete_selection`. - `SelectAutocomplete` — computes the selected command name, replaces `"/"` on the current line with `"/ "`, closes popup. - Paste handling now also checks `slash_prefix()` to keep autocomplete in sync after bracketed-paste events. --- crates/code_assistant/src/ui/terminal/app.rs | 109 ++++++++++++ .../code_assistant/src/ui/terminal/input.rs | 164 +++++++++++++++++- .../code_assistant/src/ui/terminal/state.rs | 39 +++++ 3 files changed, 303 insertions(+), 9 deletions(-) diff --git a/crates/code_assistant/src/ui/terminal/app.rs b/crates/code_assistant/src/ui/terminal/app.rs index 45f0057c..9c863bf0 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, @@ -292,6 +293,92 @@ async fn event_loop( .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; + } + 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; + } 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; + } + } + } + } + KeyEventResult::MoveAutocomplete(delta) => { + let item_count = { + let state = app_state.lock().await; + let q = &state.autocomplete_query.clone(); + all_commands() + .iter() + .filter(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }) + .count() + }; + let mut state = app_state.lock().await; + state.move_autocomplete_selection(delta, item_count); + } + 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; + } + } } needs_redraw = true; } @@ -300,6 +387,28 @@ 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; + } + Some(q) => { + let has_matches = all_commands().iter().any(|cmd| { + cmd.name.starts_with(q.as_str()) + || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) + }); + let mut state = app_state.lock().await; + if has_matches { + state.open_autocomplete(q); + input_manager.autocomplete_active = true; + } else { + state.close_autocomplete(); + input_manager.autocomplete_active = false; + } + } + } needs_redraw = true; } Event::Resize(_, _) => { diff --git a/crates/code_assistant/src/ui/terminal/input.rs b/crates/code_assistant/src/ui/terminal/input.rs index 167e5615..c6983926 100644 --- a/crates/code_assistant/src/ui/terminal/input.rs +++ b/crates/code_assistant/src/ui/terminal/input.rs @@ -38,6 +38,17 @@ pub enum KeyEventResult { 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 @@ -52,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 { @@ -64,6 +80,7 @@ impl InputManager { image_counter: 0, pending_pastes: Vec::new(), large_paste_counters: HashMap::new(), + autocomplete_active: false, } } @@ -88,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, @@ -150,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"); @@ -284,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(); @@ -341,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)); @@ -432,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/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; + } } From 81f6cccc2c9689a2a9ffcb18b63e600b2c50c0ff Mon Sep 17 00:00:00 2001 From: Yamac Ay Date: Wed, 10 Jun 2026 18:43:32 +0200 Subject: [PATCH 6/6] feat: render slash-command autocomplete popup in TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the visual layer for the autocomplete popup. Typing '/' in the composer now shows a floating list of matching commands above the input area; arrow keys move the highlight and Tab accepts. ## renderer.rs - Two new fields on `TerminalRenderer`: `autocomplete_items` (pre-filtered list of (name, description) pairs) and `autocomplete_selected` (highlighted row index). - `set_autocomplete(items, selected)` and `clear_autocomplete()` public setters. - `render_autocomplete_popup(f, input_area, items, selected)` — static helper that draws the popup directly over the Ratatui buffer: - Height: min(items.len(), 8 rows), no border. - Horizontally aligned under the "›" prefix of the composer. - Each row: " /name — description"; selected row gets a DarkGray background with Cyan name and White description. - Scrolls to keep the selected item in view. - Called from `paint()` after `composer.render()` so the popup overlays the input area. ## app.rs - `UpdateAutocomplete`, `MoveAutocomplete`, and `SelectAutocomplete` arms now also call `renderer.set_autocomplete()` / `renderer.clear_autocomplete()` so the renderer is always in sync with `AppState`. - Paste event handling likewise drives the renderer. --- crates/code_assistant/src/ui/terminal/app.rs | 67 +++++++-- .../src/ui/terminal/renderer.rs | 138 ++++++++++++++++++ 2 files changed, 191 insertions(+), 14 deletions(-) diff --git a/crates/code_assistant/src/ui/terminal/app.rs b/crates/code_assistant/src/ui/terminal/app.rs index 9c863bf0..1501e752 100644 --- a/crates/code_assistant/src/ui/terminal/app.rs +++ b/crates/code_assistant/src/ui/terminal/app.rs @@ -300,6 +300,8 @@ async fn event_loop( 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() @@ -313,6 +315,8 @@ async fn event_loop( 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 @@ -321,24 +325,40 @@ async fn event_loop( 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 = { + let (item_count, items) = { let state = app_state.lock().await; - let q = &state.autocomplete_query.clone(); - all_commands() + 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())) }) - .count() + .collect(); + let count = filtered.len(); + let items: Vec<_> = filtered + .iter() + .map(|cmd| (cmd.name, cmd.description)) + .collect(); + (count, items) }; - let mut state = app_state.lock().await; - state.move_autocomplete_selection(delta, item_count); + 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. @@ -377,6 +397,8 @@ async fn event_loop( let mut state = app_state.lock().await; state.close_autocomplete(); input_manager.autocomplete_active = false; + drop(state); + renderer.lock().await.clear_autocomplete(); } } } @@ -393,19 +415,36 @@ async fn event_loop( 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 has_matches = all_commands().iter().any(|cmd| { - cmd.name.starts_with(q.as_str()) - || cmd.aliases.iter().any(|a| a.starts_with(q.as_str())) - }); + 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 has_matches { - state.open_autocomplete(q); - input_manager.autocomplete_active = true; - } else { + 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); } } } 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);