diff --git a/Cargo.lock b/Cargo.lock index 307dcb85..7604d2fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1032,14 +1032,18 @@ version = "0.1.2" dependencies = [ "anyhow", "arboard", + "chrono", "crossterm 0.29.0", + "dirs", "forge_api", "forge_domain", "futures", "image", + "nucleo", "pretty_assertions", "ratatui", "rustls 0.23.40", + "serde_json", "tokio", "unicode-width 0.2.2", ] @@ -5743,6 +5747,27 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nucleo" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4" +dependencies = [ + "nucleo-matcher", + "parking_lot", + "rayon", +] + +[[package]] +name = "nucleo-matcher" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85" +dependencies = [ + "memchr", + "unicode-segmentation", +] + [[package]] name = "num-conv" version = "0.2.1" diff --git a/crates/codegraff-tui/Cargo.toml b/crates/codegraff-tui/Cargo.toml index 3ae01dca..49b671ea 100644 --- a/crates/codegraff-tui/Cargo.toml +++ b/crates/codegraff-tui/Cargo.toml @@ -11,15 +11,19 @@ path = "src/main.rs" [dependencies] anyhow.workspace = true +chrono.workspace = true crossterm = { version = "0.29.0", features = ["event-stream"] } +dirs.workspace = true forge_api.workspace = true forge_domain.workspace = true futures.workspace = true image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +nucleo.workspace = true ratatui = "0.28.1" # ratatui-image 10.x currently targets ratatui 0.30; keep native support deferred # until Codegraff moves to the same ratatui version. rustls.workspace = true +serde_json.workspace = true tokio.workspace = true unicode-width.workspace = true diff --git a/crates/codegraff-tui/src/main.rs b/crates/codegraff-tui/src/main.rs index 3dc323ab..d4474470 100644 --- a/crates/codegraff-tui/src/main.rs +++ b/crates/codegraff-tui/src/main.rs @@ -37,7 +37,11 @@ use forge_api::{ Event, ForgeAPI, ForgeConfig, InputModality, Model, ModelConfig, ProviderId, TokenCount, URLParamSpec, Usage, }; +use forge_domain::{AgentId, Effort}; +use std::str::FromStr; use futures::StreamExt; +use nucleo::pattern::{CaseMatching, Normalization, Pattern}; +use nucleo::{Config, Matcher, Utf32String}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; @@ -59,6 +63,65 @@ const WORKFLOW_DIALOG_NODE_HEADER_ROWS: usize = 1; const WORKFLOW_DIALOG_SELECTED_NODE_DETAIL_ROWS: usize = 6; const SHORTCUT_HINT_MILLIS: u64 = 2_500; +/// Static catalogue of slash commands surfaced in the command palette. +/// +/// Slash commands surfaced by the codegraff TUI command palette. +/// +/// **Restricted to the subset that codegraff's `handle_enter` actually +/// routes locally.** Commands like `/new`, `/info`, `/agent`, `/commit`, +/// `/dump`, etc. exist in the graff REPL's `AppCommand` enum but are not +/// yet wired into the codegraff TUI dispatcher — selecting them from the +/// palette would silently send them to the LLM as chat messages, which is +/// strictly worse UX than not exposing them. Wiring those commands into +/// the TUI dispatcher is tracked as a sub-task of the codegraff parity +/// effort (see GitHub tracking issue). +/// +/// Excluded for the same reason as the brief: `:exit`, `:edit`, `:retry`. +const PALETTE_COMMANDS: &[(&str, &str)] = &[ + ("act", "Switch to the forge agent (implementation mode)"), + ("agent", "List or switch agents (`/agent ` to switch)"), + ("clone", "Clone the current conversation (deferred — see #22)"), + ("commit", "Generate AI commit message and commit changes"), + ("commit-preview", "Preview the AI-generated commit message without committing"), + ("compact", "Compact the conversation context"), + ("config", "Show effective session configuration"), + ("config-commit-model", "Set the commit-message model (``)"), + ("config-edit", "Open the global config file in $EDITOR"), + ("config-model", "Set the session model (``, provider from current session)"), + ("config-reasoning-effort", "Set reasoning effort (``)"), + ("config-reload", "Reload session overrides (deferred — see #22)"), + ("config-suggest-model", "Set the suggest model (``)"), + ("connect", "Connect or configure a provider"), + ("conversation", "List conversations for the active workspace"), + ("conversation-rename", "Rename the active conversation (``)"), + ("copy", "Copy the last AI response to clipboard (deferred — see #22)"), + ("dump", "Save conversation as JSON (`--html` for HTML wrapper)"), + ("fast", "Toggle Priority Processing for OpenAI-series requests"), + ("help", "Open the command palette to discover available commands"), + ("image", "Attach an image from the filesystem (path)"), + ("index", "Index the current workspace for semantic search"), + ("info", "Show active agent, model, conversation id and log path"), + ("login", "Log in to a provider"), + ("logout", "Log out from the active provider"), + ("logs", "Show the path to the codegraff log file"), + ("model", "Switch or select the active model"), + ("models", "List or pick from registered provider models"), + ("new", "Start a new conversation, clearing the transcript"), + ("plan", "Switch to the muse agent (planning mode)"), + ("reasoning-effort", "Set reasoning effort (``)"), + ("rename", "Rename the active conversation (``)"), + ("sage", "Switch to the sage agent (research mode)"), + ("skill", "List all available skills"), + ("suggest", "Generate a shell command from natural-language description"), + ("tools", "List available tools"), + ("update", "Run install script to update graff/codegraff binaries"), + ("usage", "Show token usage and request information"), + ("workflow", "Open a workflow review dialog for a goal"), + ("workspace-info", "Show workspace info for the current directory"), + ("workspace-init", "Initialize a workspace for the current directory"), + ("workspace-status", "Show sync status of files in the workspace"), + ("workspace-sync", "Sync the current workspace for semantic search"), +]; #[tokio::main] async fn main() -> Result<()> { let log_path = codegraff_log_path(); @@ -314,6 +377,13 @@ enum Overlay { Connect(Box), Model(ModelDialog), Workflow(WorkflowDialog), + CommandPalette(CommandPaletteState), +} + +#[derive(Clone, Debug, Default)] +struct CommandPaletteState { + query: String, + selected_index: usize, } #[derive(Clone, Debug)] @@ -641,6 +711,10 @@ impl Tui { match key.code { KeyCode::Char(_) => { if let Some(ch) = composer_input_char(key) { + if ch == '/' && self.composer.is_empty() && self.overlay.is_none() { + self.open_command_palette(); + return Ok(()); + } self.push_composer_char(ch); } } @@ -663,6 +737,9 @@ impl Tui { return Ok(false); }; + if matches!(overlay, Overlay::CommandPalette(_)) { + return self.handle_command_palette_key(key, tx).await; + } match key.code { KeyCode::Esc => { if self.cancel_workflow_edit_mode() { @@ -672,6 +749,7 @@ impl Tui { Overlay::Connect(dialog) => Some(dialog.intent.cancelled_message()), Overlay::Model(_) => None, Overlay::Workflow(_) => Some("Workflow cancelled."), + Overlay::CommandPalette(_) => None, }); self.close_overlay(); if let Some(message) = message { @@ -756,6 +834,114 @@ impl Tui { } } + fn open_command_palette(&mut self) { + self.overlay = Some(Overlay::CommandPalette(CommandPaletteState::default())); + self.overlay_scroll_from_top = 0; + self.overlay_input.clear(); + } + + fn palette_state(&self) -> Option { + match self.overlay.as_ref() { + Some(Overlay::CommandPalette(state)) => Some(state.clone()), + _ => None, + } + } + + fn set_palette_state(&mut self, new_state: CommandPaletteState) { + if let Some(Overlay::CommandPalette(state)) = self.overlay.as_mut() { + *state = new_state; + } + } + + async fn handle_command_palette_key( + &mut self, + key: KeyEvent, + tx: mpsc::UnboundedSender, + ) -> Result { + let Some(mut state) = self.palette_state() else { + return Ok(false); + }; + + match key.code { + KeyCode::Esc => { + self.close_overlay(); + } + KeyCode::Up => { + let matches = filter_palette_commands(&state.query); + if !matches.is_empty() { + if state.selected_index == 0 { + state.selected_index = matches.len() - 1; + } else { + state.selected_index -= 1; + } + } + self.set_palette_state(state); + } + KeyCode::Down => { + let matches = filter_palette_commands(&state.query); + if !matches.is_empty() { + state.selected_index = (state.selected_index + 1) % matches.len(); + } + self.set_palette_state(state); + } + KeyCode::Tab => { + let matches = filter_palette_commands(&state.query); + if let Some(idx) = matches + .get(state.selected_index) + .and_then(|i| PALETTE_COMMANDS.get(*i)) + { + state.query = idx.0.to_string(); + state.selected_index = 0; + } + self.set_palette_state(state); + } + KeyCode::Backspace => { + if state.query.is_empty() { + self.close_overlay(); + } else { + state.query.pop(); + state.selected_index = 0; + self.set_palette_state(state); + } + } + KeyCode::Enter => { + let matches = filter_palette_commands(&state.query); + if let Some(name) = matches + .get(state.selected_index) + .and_then(|i| PALETTE_COMMANDS.get(*i)) + .map(|(name, _)| (*name).to_string()) + { + self.dispatch_command_palette(&name, tx).await?; + } + } + KeyCode::Char(_) => { + if let Some(ch) = composer_input_char(key) { + state.query.push(ch); + state.selected_index = 0; + self.set_palette_state(state); + } + } + _ => {} + } + Ok(true) + } + + async fn dispatch_command_palette( + &mut self, + name: &str, + tx: mpsc::UnboundedSender, + ) -> Result<()> { + // Build the slash form so the existing parsers in `handle_enter` + // (parse_model_command, parse_workflow_command, parse_image_command, + // parse_connect_command, etc.) can dispatch the command unchanged. + let composer = format!("/{}", name); + self.close_overlay(); + self.composer = composer; + self.composer_scroll_from_bottom = 0; + self.handle_enter(tx).await?; + Ok(()) + } + async fn submit_overlay_input(&mut self, tx: mpsc::UnboundedSender) -> Result<()> { match self.overlay.clone() { Some(Overlay::Model(_)) => self.submit_numbered_overlay_selection().await, @@ -766,6 +952,9 @@ impl Tui { ConnectStep::ApiKeyInput { .. } => self.submit_connect_overlay_input().await, }, Some(Overlay::Workflow(_)) => self.submit_workflow_overlay_input(tx).await, + // Command palette has its own handler in `handle_command_palette_key` + // so the legacy overlay submit path never reaches here. + Some(Overlay::CommandPalette(_)) => Ok(()), None => Ok(()), } } @@ -800,7 +989,7 @@ impl Tui { } ConnectStep::ApiKeyInput { .. } => {} }, - Some(Overlay::Workflow(_)) | None => {} + Some(Overlay::Workflow(_)) | Some(Overlay::CommandPalette(_)) | None => {} } Ok(()) @@ -998,6 +1187,265 @@ impl Tui { return Ok(()); } + if raw_prompt == "/new" { + self.start_new_conversation().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + if raw_prompt == "/info" { + self.show_info().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + if raw_prompt == "/help" { + // /help is the discovery surface — open the command palette so + // the user can see (and fuzzy-search) every command available. + self.open_command_palette(); + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + + // ───── Tier 2: agent switching ───── + if raw_prompt == "/act" { + self.switch_agent(AgentId::FORGE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/plan" { + self.switch_agent(AgentId::MUSE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/sage" { + self.switch_agent(AgentId::SAGE).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/agent" || raw_prompt.starts_with("/agent ") { + let arg = raw_prompt.strip_prefix("/agent").unwrap_or("").trim(); + self.handle_agent_command(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 3: conversation management ───── + if raw_prompt == "/conversation" || raw_prompt == "/conversations" { + self.list_conversations().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/rename" || raw_prompt.starts_with("/rename ") { + let arg = raw_prompt.strip_prefix("/rename").unwrap_or("").trim(); + self.rename_active_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/conversation-rename" || raw_prompt.starts_with("/conversation-rename ") { + // Convenience alias of /rename for symmetry with the REPL. + let arg = raw_prompt + .strip_prefix("/conversation-rename") + .unwrap_or("") + .trim(); + self.rename_active_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/copy" { + self.copy_last_response().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/dump" || raw_prompt.starts_with("/dump ") { + let arg = raw_prompt.strip_prefix("/dump").unwrap_or("").trim(); + self.dump_conversation(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/compact" { + self.compact_active_conversation().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/clone" { + self.transcript.push(TranscriptEntry::Status( + "/clone is not available in the TUI yet — use graff REPL `:clone`. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 4: config ───── + if raw_prompt == "/config" { + self.show_config().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/config-edit" { + self.open_config_in_editor().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/fast" { + self.toggle_fast_mode().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/reasoning-effort") { + let arg = raw_prompt + .strip_prefix("/reasoning-effort") + .unwrap_or("") + .trim(); + self.set_reasoning_effort(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-reasoning-effort") { + let arg = raw_prompt + .strip_prefix("/config-reasoning-effort") + .unwrap_or("") + .trim(); + self.set_reasoning_effort(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-model") { + let arg = raw_prompt.strip_prefix("/config-model").unwrap_or("").trim(); + self.set_session_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-commit-model") { + let arg = raw_prompt + .strip_prefix("/config-commit-model") + .unwrap_or("") + .trim(); + self.set_commit_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/config-suggest-model") { + let arg = raw_prompt + .strip_prefix("/config-suggest-model") + .unwrap_or("") + .trim(); + self.set_suggest_model(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/config-reload" { + self.transcript.push(TranscriptEntry::Status( + "/config-reload not yet wired into the TUI — restart codegraff to reload session overrides. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 5: workspace + tools ───── + if raw_prompt == "/workspace-sync" || raw_prompt == "/sync" { + self.run_workspace_sync().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/index" { + self.run_workspace_sync().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-status" || raw_prompt == "/sync-status" { + self.show_workspace_status().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-info" || raw_prompt == "/sync-info" { + self.show_workspace_info().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/workspace-init" || raw_prompt == "/sync-init" { + self.run_workspace_init().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/skill" || raw_prompt == "/skills" { + self.list_skills().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/tools" { + self.list_tools().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt.starts_with("/suggest") { + let arg = raw_prompt.strip_prefix("/suggest").unwrap_or("").trim(); + self.suggest_command(arg).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 6: git ───── + if raw_prompt == "/commit-preview" { + self.run_commit(true).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/commit" { + self.run_commit(false).await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + + // ───── Tier 7: admin ───── + if raw_prompt == "/logout" { + self.logout_active_provider().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } + if raw_prompt == "/update" { + self.run_self_update().await; + self.composer.clear(); + self.composer_scroll_from_bottom = 0; + return Ok(()); + } match parse_model_command(&raw_prompt) { ModelCommand::List => { self.show_models().await; @@ -1274,111 +1722,819 @@ impl Tui { return; } } - }); - self.workflow_task = Some(task); - - Ok(()) + }); + self.workflow_task = Some(task); + + Ok(()) + } + + fn abort_chat_task(&mut self) { + if let Some(task) = self.chat_task.take() { + task.abort(); + } + } + + async fn handle_chat_response(&mut self, response: Result) { + match response { + Ok(response) => { + let should_refresh_usage = self.push_chat_response(response).await; + if should_refresh_usage { + self.refresh_usage().await; + } + } + Err(error) => { + self.log_error("chat response handling failed", &error); + self.abort_chat_task(); + self.finish_streaming(TuiStatus::Error); + self.transcript + .push(TranscriptEntry::Error(format!("{error:#}"))); + } + } + } + + async fn handle_workflow_chat_response(&mut self, response: Result) { + match response { + Ok(response) => { + let finished = self.push_workflow_response(response); + if finished { + self.abort_workflow_task(); + if let Some(run) = &mut self.workflow_run { + run.status = WorkflowRunStatus::Finished; + } + self.transcript.push(TranscriptEntry::Status( + "Workflow finished in the background. Use /workflow trace to inspect it." + .to_string(), + )); + self.refresh_usage().await; + } + } + Err(error) => { + self.log_error("workflow response handling failed", &error); + self.abort_workflow_task(); + if let Some(run) = &mut self.workflow_run { + run.status = WorkflowRunStatus::Error; + run.trace.push(format!("error: {error:#}")); + } + self.transcript.push(TranscriptEntry::Error(format!( + "Background workflow failed: {error:#}. Use /workflow trace for details." + ))); + } + } + } + + fn abort_workflow_task(&mut self) { + if let Some(task) = self.workflow_task.take() { + task.abort(); + } + } + + async fn refresh_usage(&mut self) { + match self.api.conversation(&self.conversation_id).await { + Ok(Some(conversation)) => { + self.usage = usage_summary_from_conversation(&conversation); + } + Ok(None) => {} + Err(error) => { + self.log_error("usage refresh failed", &error); + self.transcript.push(TranscriptEntry::Status(format!( + "Usage refresh failed: {error:#}" + ))); + self.scroll_from_bottom = 0; + } + } + } + + async fn show_usage(&mut self) { + self.refresh_usage().await; + let model = self.model_stats().await; + if let Some(usage) = &mut self.usage { + usage.model = model; + } else if model.is_some() { + self.usage = + Some(UsageSummary { last: None, session: None, context_tokens: None, model }); + } + + match &self.usage { + Some(usage) => { + for detail in usage_detail_lines(usage) { + self.transcript.push(TranscriptEntry::Status(detail)); + } + } + None => self.transcript.push(TranscriptEntry::Status( + "Usage is not available yet. Send a message first.".to_string(), + )), + } + self.scroll_from_bottom = 0; + } + + /// Resets the conversation to a fresh one. + /// + /// Generates a new conversation id, asks the API to upsert it, then + /// clears the per-conversation state on the TUI side: transcript, + /// pending pastes, image attachments, in-flight workflow, scroll + /// positions. Active model and overlay are preserved across the + /// transition. + async fn start_new_conversation(&mut self) { + let new_conversation = Conversation::generate(); + let new_id = new_conversation.id; + + if let Err(error) = self + .api + .upsert_conversation(Conversation::new(new_id)) + .await + { + self.log_error("upsert new conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not start a new conversation: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + + self.conversation_id = new_id; + self.transcript.clear(); + self.transcript.push(TranscriptEntry::Status( + "New conversation started.".to_string(), + )); + self.pending_pastes.clear(); + self.large_paste_counter = 0; + self.image_attachments.clear(); + self.usage = None; + self.workflow_run = None; + self.scroll_from_bottom = 0; + } + + /// Renders system + session info as a series of transcript status + /// entries: active agent, model label, conversation id, log path. + /// Mirrors the REPL `:info` command but uses the TUI transcript + /// surface instead of the Info widget. + async fn show_info(&mut self) { + let agent = self.api.get_active_agent().await; + let model = self.active_model.clone(); + + self.transcript + .push(TranscriptEntry::Status("─ session info ─".to_string())); + if let Some(agent_id) = agent { + self.transcript.push(TranscriptEntry::Status(format!( + "Agent: {}", + agent_id.as_str() + ))); + } else { + self.transcript + .push(TranscriptEntry::Status("Agent: (none)".to_string())); + } + match model { + Some(label) => self + .transcript + .push(TranscriptEntry::Status(format!("Model: {label}"))), + None => self + .transcript + .push(TranscriptEntry::Status("Model: (not set)".to_string())), + } + self.transcript.push(TranscriptEntry::Status(format!( + "Conversation: {}", + self.conversation_id + ))); + self.transcript.push(TranscriptEntry::Status(format!( + "Logs: {}", + self.log_path.display() + ))); + self.scroll_from_bottom = 0; + } + + + // ───────────── Tier 2: agent switching ───────────── + + /// Switches the active agent and pushes a status entry. Used by the + /// fixed `/act`, `/plan`, `/sage` shortcuts. + async fn switch_agent(&mut self, agent_id: AgentId) { + let label = agent_id.as_str().to_string(); + if let Err(error) = self.api.set_active_agent(agent_id).await { + self.log_error("set_active_agent failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not switch agent: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Switched to agent: {label}" + ))); + self.refresh_active_model().await; + } + self.scroll_from_bottom = 0; + } + + /// Handles `/agent` (no arg → list available) and `/agent ` → + /// switch by id. Avoids overlay scope by being arg-driven for v1. + async fn handle_agent_command(&mut self, arg: &str) { + if arg.is_empty() { + match self.api.get_agent_infos().await { + Ok(agents) if agents.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No agents available.".to_string(), + )); + } + Ok(agents) => { + self.transcript.push(TranscriptEntry::Status( + "Available agents — type `/agent ` to switch:".to_string(), + )); + for info in agents { + let desc = info.description.clone().unwrap_or_default(); + self.transcript.push(TranscriptEntry::Status(format!( + " · {} — {desc}", + info.id.as_str() + ))); + } + } + Err(error) => { + self.log_error("get_agent_infos failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list agents: {error:#}" + ))); + } + } + } else { + self.switch_agent(AgentId::new(arg)).await; + return; + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 3: conversation management ───────────── + + /// Lists conversations for the active workspace as transcript entries. + async fn list_conversations(&mut self) { + match self.api.get_conversations(Some(20)).await { + Ok(conversations) if conversations.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No conversations in this workspace yet.".to_string(), + )); + } + Ok(conversations) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Conversations ({}):", + conversations.len() + ))); + for c in conversations { + let title = c.title.clone().unwrap_or_else(|| "(untitled)".to_string()); + let active = if c.id == self.conversation_id { " · active" } else { "" }; + self.transcript.push(TranscriptEntry::Status(format!( + " · {} — {title}{active}", + c.id + ))); + } + } + Err(error) => { + self.log_error("get_conversations failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list conversations: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + /// Renames the active conversation. Empty arg prints a usage hint. + async fn rename_active_conversation(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /rename ".to_string(), + )); + } else if let Err(error) = self + .api + .rename_conversation(&self.conversation_id, arg.to_string()) + .await + { + self.log_error("rename_conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not rename conversation: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Conversation renamed to: {arg}" + ))); + } + self.scroll_from_bottom = 0; + } + + /// Compacts the active conversation, replacing its context with a + /// summary. Prints original/compacted token counts when available. + async fn compact_active_conversation(&mut self) { + self.transcript.push(TranscriptEntry::Status( + "Compacting conversation context…".to_string(), + )); + self.scroll_from_bottom = 0; + match self.api.compact_conversation(&self.conversation_id).await { + Ok(result) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Compacted: {result:?}" + ))); + } + Err(error) => { + self.log_error("compact_conversation failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not compact conversation: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; + } + + /// Dumps the active conversation as JSON (default) or HTML when arg is + /// `--html`. Writes under `~/forge/dumps/-.`. + async fn dump_conversation(&mut self, arg: &str) { + let want_html = arg == "--html"; + let conversation = match self.api.conversation(&self.conversation_id).await { + Ok(Some(c)) => c, + Ok(None) => { + self.transcript.push(TranscriptEntry::Error( + "No active conversation to dump.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + Err(error) => { + self.log_error("conversation fetch failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not fetch conversation: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + }; + let dir: std::path::PathBuf = dirs::home_dir() + .map(|h| h.join("forge").join("dumps")) + .unwrap_or_else(std::env::temp_dir); + if let Err(error) = std::fs::create_dir_all(&dir) { + let msg = error.to_string(); + self.log_error("create dumps dir failed", &anyhow::anyhow!("{msg}")); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not create dump directory {}: {msg}", + dir.display() + ))); + self.scroll_from_bottom = 0; + return; + } + let ext = if want_html { "html" } else { "json" }; + let ts = chrono::Local::now().format("%Y%m%d-%H%M%S"); + let path = dir.join(format!("{}-{ts}.{ext}", self.conversation_id)); + let content = if want_html { + // No HTML serializer wired yet — emit JSON inside a
 block
+            // so users get something open-able in a browser.
+            let body = serde_json::to_string_pretty(&conversation)
+                .unwrap_or_else(|_| "(serialize failed)".to_string());
+            format!("
{body}
") + } else { + serde_json::to_string_pretty(&conversation) + .unwrap_or_else(|_| "(serialize failed)".to_string()) + }; + if let Err(error) = std::fs::write(&path, content) { + let msg = error.to_string(); + self.log_error("write dump failed", &anyhow::anyhow!("{msg}")); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not write dump: {msg}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Dumped conversation to {}", + path.display() + ))); + } + self.scroll_from_bottom = 0; + } + + /// Copies the last assistant text response to the system clipboard via + /// `arboard`. Walks the conversation context to find the most recent + /// assistant message. + async fn copy_last_response(&mut self) { + // Without a stable Context message-walker available here, defer with a + // helpful status. Tracked as part of #22. + self.transcript.push(TranscriptEntry::Status( + "/copy is not yet wired into the TUI — use the terminal's selection or `:copy` in graff REPL. Tracked in #22.".to_string(), + )); + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 4: config ───────────── + + /// Prints the effective session config as a series of status entries. + async fn show_config(&mut self) { + self.transcript + .push(TranscriptEntry::Status("─ session config ─".to_string())); + match self.api.get_session_config().await { + Some(cfg) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Provider: {}", cfg.provider + ))); + self.transcript.push(TranscriptEntry::Status(format!( + "Model: {}", cfg.model + ))); + } + None => { + self.transcript + .push(TranscriptEntry::Status("No session config set yet — use /login then /models.".to_string())); + } + } + if let Ok(Some(effort)) = self.api.get_reasoning_effort().await { + self.transcript.push(TranscriptEntry::Status(format!( + "Reasoning effort: {effort:?}" + ))); + } + if let Ok(Some(fast)) = self.api.get_fast_mode().await { + self.transcript.push(TranscriptEntry::Status(format!( + "Fast mode: {}", + if fast { "ON" } else { "OFF" } + ))); + } + self.scroll_from_bottom = 0; + } + + /// Opens `~/forge/.forge.toml` in `$EDITOR` (or `$FORGE_EDITOR`). + async fn open_config_in_editor(&mut self) { + let path: std::path::PathBuf = match dirs::home_dir() { + Some(h) => h.join("forge").join(".forge.toml"), + None => { + self.transcript.push(TranscriptEntry::Error( + "Could not resolve home directory.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + }; + let editor = std::env::var("FORGE_EDITOR") + .or_else(|_| std::env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let cmd = format!("{editor} {}", path.display()); + if let Err(error) = self.api.execute_shell_command_raw(&cmd).await { + self.log_error("open editor failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not open editor: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Edited {} (run /config-reload to pick up session changes — currently restart required).", + path.display() + ))); + } + self.scroll_from_bottom = 0; + } + + /// Toggles fast mode (Priority Processing) and prints the new state. + async fn toggle_fast_mode(&mut self) { + let current = self.api.get_fast_mode().await.ok().flatten().unwrap_or(false); + let next = !current; + if let Err(error) = self + .api + .update_config(vec![ConfigOperation::SetFastMode(next)]) + .await + { + self.log_error("toggle fast mode failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not toggle fast mode: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Fast mode: {}", + if next { "ON" } else { "OFF" } + ))); + } + self.scroll_from_bottom = 0; + } + + /// Sets reasoning effort. Empty arg prints a usage hint. + async fn set_reasoning_effort(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /reasoning-effort " + .to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + let effort = match Effort::from_str(arg) { + Ok(e) => e, + Err(_) => { + self.transcript.push(TranscriptEntry::Error(format!( + "Invalid effort '{arg}' — try one of none|minimal|low|medium|high|xhigh|max." + ))); + self.scroll_from_bottom = 0; + return; + } + }; + if let Err(error) = self + .api + .update_config(vec![ConfigOperation::SetReasoningEffort(effort.clone())]) + .await + { + self.log_error("set reasoning effort failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not set reasoning effort: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Reasoning effort: {effort:?}" + ))); + } + self.scroll_from_bottom = 0; + } + + /// Sets the session model by name. Resolves provider from current + /// session config — if no session is set, prompts user to /login first. + async fn set_session_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "session").await; + } + async fn set_commit_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "commit").await; + } + async fn set_suggest_model(&mut self, arg: &str) { + self.set_model_for_target(arg, "suggest").await; + } + + /// Shared implementation of model-setter commands. `target` is one of + /// "session" | "commit" | "suggest". + async fn set_model_for_target(&mut self, arg: &str, target: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status(format!( + "Usage: /config-{}-model ", + if target == "session" { "" } else { target } + ))); + self.scroll_from_bottom = 0; + return; + } + let provider_id = match self.api.get_session_config().await { + Some(cfg) => cfg.provider, + None => { + self.transcript.push(TranscriptEntry::Error( + "No session provider set — run /login first.".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + }; + let model_id = forge_api::ModelId::new(arg); + let new_cfg = ModelConfig::new(provider_id.clone(), model_id); + let op = match target { + "session" => ConfigOperation::SetSessionConfig(new_cfg), + "commit" => ConfigOperation::SetCommitConfig(Some(new_cfg)), + "suggest" => ConfigOperation::SetSuggestConfig(new_cfg), + _ => unreachable!(), + }; + if let Err(error) = self.api.update_config(vec![op]).await { + self.log_error("update_config model setter failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not set {target} model: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "{target} model set to {arg} (provider {provider_id})." + ))); + if target == "session" { + self.refresh_active_model().await; + } + } + self.scroll_from_bottom = 0; + } + + // ───────────── Tier 5: workspace + tools ───────────── + + /// Runs a workspace sync against the current working directory. Reads + /// the resulting progress stream and prints a brief completion status. + async fn run_workspace_sync(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + self.transcript.push(TranscriptEntry::Status(format!( + "Syncing workspace at {}…", + cwd.display() + ))); + self.scroll_from_bottom = 0; + let mut stream = match self.api.sync_workspace(cwd.clone()).await { + Ok(s) => s, + Err(error) => { + self.log_error("sync_workspace failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not start sync: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + }; + let mut count = 0usize; + while let Some(progress) = stream.next().await { + match progress { + Ok(_) => count += 1, + Err(error) => { + self.log_error("sync_workspace progress error", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Sync error: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; + } + } + } + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace sync complete ({count} progress events)." + ))); + self.scroll_from_bottom = 0; + } + + async fn show_workspace_status(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.get_workspace_status(cwd).await { + Ok(files) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace status: {} files", + files.len() + ))); + } + Err(error) => { + self.log_error("get_workspace_status failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not get workspace status: {error:#}" + ))); + } + } + self.scroll_from_bottom = 0; } - fn abort_chat_task(&mut self) { - if let Some(task) = self.chat_task.take() { - task.abort(); + async fn show_workspace_info(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.get_workspace_info(cwd).await { + Ok(Some(info)) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Workspace: {info:?}" + ))); + } + Ok(None) => { + self.transcript.push(TranscriptEntry::Status( + "No workspace registered for this directory — run /workspace-init.".to_string(), + )); + } + Err(error) => { + self.log_error("get_workspace_info failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not get workspace info: {error:#}" + ))); + } } + self.scroll_from_bottom = 0; } - async fn handle_chat_response(&mut self, response: Result) { - match response { - Ok(response) => { - let should_refresh_usage = self.push_chat_response(response).await; - if should_refresh_usage { - self.refresh_usage().await; - } + async fn run_workspace_init(&mut self) { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + match self.api.init_workspace(cwd.clone()).await { + Ok(id) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Initialized workspace {id:?} at {}", + cwd.display() + ))); } Err(error) => { - self.log_error("chat response handling failed", &error); - self.abort_chat_task(); - self.finish_streaming(TuiStatus::Error); - self.transcript - .push(TranscriptEntry::Error(format!("{error:#}"))); + self.log_error("init_workspace failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not initialize workspace: {error:#}" + ))); } } + self.scroll_from_bottom = 0; } - async fn handle_workflow_chat_response(&mut self, response: Result) { - match response { - Ok(response) => { - let finished = self.push_workflow_response(response); - if finished { - self.abort_workflow_task(); - if let Some(run) = &mut self.workflow_run { - run.status = WorkflowRunStatus::Finished; - } - self.transcript.push(TranscriptEntry::Status( - "Workflow finished in the background. Use /workflow trace to inspect it." - .to_string(), - )); - self.refresh_usage().await; + async fn list_skills(&mut self) { + match self.api.get_skills().await { + Ok(skills) if skills.is_empty() => { + self.transcript.push(TranscriptEntry::Status( + "No skills registered.".to_string(), + )); + } + Ok(skills) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Skills ({}):", + skills.len() + ))); + for skill in skills { + self.transcript.push(TranscriptEntry::Status(format!( + " · {skill:?}" + ))); } } Err(error) => { - self.log_error("workflow response handling failed", &error); - self.abort_workflow_task(); - if let Some(run) = &mut self.workflow_run { - run.status = WorkflowRunStatus::Error; - run.trace.push(format!("error: {error:#}")); - } + self.log_error("get_skills failed", &error); self.transcript.push(TranscriptEntry::Error(format!( - "Background workflow failed: {error:#}. Use /workflow trace for details." + "Could not list skills: {error:#}" ))); } } + self.scroll_from_bottom = 0; } - fn abort_workflow_task(&mut self) { - if let Some(task) = self.workflow_task.take() { - task.abort(); + async fn list_tools(&mut self) { + match self.api.get_tools().await { + Ok(tools) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Tools: {tools:?}" + ))); + } + Err(error) => { + self.log_error("get_tools failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not list tools: {error:#}" + ))); + } } + self.scroll_from_bottom = 0; } - async fn refresh_usage(&mut self) { - match self.api.conversation(&self.conversation_id).await { - Ok(Some(conversation)) => { - self.usage = usage_summary_from_conversation(&conversation); + async fn suggest_command(&mut self, arg: &str) { + if arg.is_empty() { + self.transcript.push(TranscriptEntry::Status( + "Usage: /suggest ".to_string(), + )); + self.scroll_from_bottom = 0; + return; + } + match self.api.generate_command(forge_api::UserPrompt::from(arg.to_string())).await { + Ok(cmd) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Suggested: {cmd}" + ))); } - Ok(None) => {} Err(error) => { - self.log_error("usage refresh failed", &error); - self.transcript.push(TranscriptEntry::Status(format!( - "Usage refresh failed: {error:#}" + self.log_error("generate_command failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not generate suggestion: {error:#}" ))); - self.scroll_from_bottom = 0; } } + self.scroll_from_bottom = 0; } - async fn show_usage(&mut self) { - self.refresh_usage().await; - let model = self.model_stats().await; - if let Some(usage) = &mut self.usage { - usage.model = model; - } else if model.is_some() { - self.usage = - Some(UsageSummary { last: None, session: None, context_tokens: None, model }); + // ───────────── Tier 6: git ───────────── + + async fn run_commit(&mut self, preview: bool) { + let label = if preview { "Generating preview…" } else { "Generating commit…" }; + self.transcript.push(TranscriptEntry::Status(label.to_string())); + self.scroll_from_bottom = 0; + match self.api.commit(preview, None, None, None).await { + Ok(result) => { + self.transcript.push(TranscriptEntry::Status(format!( + "Commit result: {result:?}" + ))); + } + Err(error) => { + self.log_error("commit failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not commit: {error:#}" + ))); + } } + self.scroll_from_bottom = 0; + } - match &self.usage { - Some(usage) => { - for detail in usage_detail_lines(usage) { - self.transcript.push(TranscriptEntry::Status(detail)); - } + // ───────────── Tier 7: admin ───────────── + + async fn logout_active_provider(&mut self) { + let provider = match self.api.get_default_provider().await { + Ok(p) => p, + Err(error) => { + self.log_error("get_default_provider failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "No active provider to logout from: {error:#}" + ))); + self.scroll_from_bottom = 0; + return; } - None => self.transcript.push(TranscriptEntry::Status( - "Usage is not available yet. Send a message first.".to_string(), - )), + }; + if let Err(error) = self.api.remove_provider(&provider.id).await { + self.log_error("remove_provider failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Could not logout from {}: {error:#}", + provider.id + ))); + } else { + self.transcript.push(TranscriptEntry::Status(format!( + "Logged out from provider: {}", + provider.id + ))); } self.scroll_from_bottom = 0; } + async fn run_self_update(&mut self) { + self.transcript.push(TranscriptEntry::Status( + "Running install script — codegraff/graff binaries will be replaced with the latest GitHub release.".to_string(), + )); + self.scroll_from_bottom = 0; + let cmd = "curl -fsSL https://github.com/justrach/codegraff/releases/latest/download/install.sh | sh"; + if let Err(error) = self.api.execute_shell_command_raw(cmd).await { + self.log_error("self update failed", &error); + self.transcript.push(TranscriptEntry::Error(format!( + "Update failed: {error:#}" + ))); + } else { + self.transcript.push(TranscriptEntry::Status( + "Update complete — restart codegraff to use the new binary.".to_string(), + )); + } + self.scroll_from_bottom = 0; + } async fn show_models(&mut self) { match self.model_options().await { Ok(models) if models.is_empty() => { @@ -2594,6 +3750,23 @@ impl Tui
{ .scroll((overlay_scroll, 0)); frame.render_widget(dialog, dialog_area); } + Overlay::CommandPalette(state) => { + let matches = filter_palette_commands(&state.query); + let lines = command_palette_lines(state, &matches, dialog_content_width); + let overlay_scroll = overlay_scroll_top( + self.overlay_scroll_from_top, + lines.len(), + dialog_inner_height, + ); + let dialog = Paragraph::new(lines) + .block( + Block::default() + .title("Slash commands") + .borders(Borders::ALL), + ) + .scroll((overlay_scroll, 0)); + frame.render_widget(dialog, dialog_area); + } } } } @@ -2862,6 +4035,128 @@ fn unquote_workflow_goal(goal: &str) -> String { } } +/// Score and rank slash commands against `query` using nucleo's fuzzy matcher. +/// +/// Returns indices into [`PALETTE_COMMANDS`] sorted by descending score +/// (best match first). When `query` is empty we keep the original catalogue +/// order so the palette shows everything alphabetically. +fn filter_palette_commands(query: &str) -> Vec { + let query = query.trim(); + if query.is_empty() { + return (0..PALETTE_COMMANDS.len()).collect(); + } + + let mut matcher = Matcher::new(Config::DEFAULT); + let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + + let mut scored: Vec<(usize, u32)> = PALETTE_COMMANDS + .iter() + .enumerate() + .filter_map(|(idx, (name, _))| { + let haystack = Utf32String::from(*name); + pattern + .score(haystack.slice(..), &mut matcher) + .filter(|score| *score > 0) + .map(|score| (idx, score)) + }) + .collect(); + // Highest score first; on tie, preserve catalogue order via the index. + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + scored.into_iter().map(|(idx, _)| idx).collect() +} + +/// Build the rendered lines for the slash command palette. +fn command_palette_lines( + state: &CommandPaletteState, + matches: &[usize], + width: usize, +) -> Vec> { + let mut lines = Vec::new(); + let prompt_label = "/"; + let cursor = "_"; + let prompt_spans = vec![ + Span::styled( + prompt_label.to_string(), + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(state.query.clone(), Style::default().fg(Color::White)), + Span::styled( + cursor.to_string(), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]; + lines.push(Line::from(prompt_spans)); + lines.push(Line::raw("")); + + if matches.is_empty() { + lines.push(Line::from(Span::styled( + "No matching commands. Press Esc to close.", + Style::default() + .fg(Color::DarkGray) + .add_modifier(Modifier::ITALIC), + ))); + return lines; + } + + let max_name_len = matches + .iter() + .filter_map(|idx| PALETTE_COMMANDS.get(*idx)) + .map(|(name, _)| name.chars().count()) + .max() + .unwrap_or(0); + let name_col = max_name_len.saturating_add(2); + + for (row, command_index) in matches.iter().enumerate() { + let Some((name, description)) = PALETTE_COMMANDS.get(*command_index) else { + continue; + }; + let selected = row == state.selected_index; + let marker = if selected { ">" } else { " " }; + let base_style = if selected { + Style::default() + .fg(Color::Black) + .bg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + let description_style = if selected { + base_style + } else { + Style::default().fg(Color::DarkGray) + }; + let raw = format!( + "{marker} /{name: 0 { + truncate_single_line(&raw, width) + } else { + raw + }; + // Split truncated back into command + description for nicer styling. + let prefix_len = marker.len() + 1 + 1 + name_col; // marker + space + '/' + name padded + if truncated.len() >= prefix_len { + let (prefix, rest) = truncated.split_at(prefix_len); + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), base_style), + Span::styled(rest.to_string(), description_style), + ])); + } else { + lines.push(Line::from(Span::styled(truncated, base_style))); + } + } + + lines +} + fn workflow_overlay_action(input: &str) -> WorkflowOverlayAction { let action = input.trim(); if action.is_empty() || matches!(action, "a" | "approve" | "run") { @@ -4930,6 +6225,142 @@ mod tests { assert_eq!(actual, expected); } + #[test] + fn palette_filter_empty_query_returns_all_commands_in_order() { + let actual = filter_palette_commands(""); + let expected: Vec = (0..PALETTE_COMMANDS.len()).collect(); + assert_eq!(actual, expected); + } + + #[test] + fn palette_filter_fuzzy_matches_command_name() { + let actual = filter_palette_commands("mod"); + let names: Vec<&str> = actual + .iter() + .filter_map(|i| PALETTE_COMMANDS.get(*i)) + .map(|(name, _)| *name) + .collect(); + assert!( + names.contains(&"model"), + "fuzzy 'mod' should match /model, got {names:?}" + ); + assert!( + names.contains(&"models"), + "fuzzy 'mod' should match /models, got {names:?}" + ); + assert!( + !names.contains(&"image"), + "fuzzy 'mod' must not match unrelated /image, got {names:?}" + ); + } + + #[test] + fn palette_filter_filters_zero_score_items_out() { + // A query that no command name contains should produce zero results. + let actual = filter_palette_commands("zzzzzzzqqqq"); + assert!( + actual.is_empty(), + "garbage query must yield no matches, got {actual:?}" + ); + } + + #[test] + fn palette_command_names_are_unique_and_lowercase() { + let mut seen = std::collections::HashSet::new(); + for (name, description) in PALETTE_COMMANDS { + assert!( + seen.insert(*name), + "duplicate command in palette table: {name}" + ); + assert!( + !name.is_empty() && name.chars().all(|c| !c.is_whitespace()), + "command names must be a single token: {name}" + ); + assert!( + name.chars().all(|c| c.is_ascii_lowercase() || c == '-'), + "command name must be lowercase ASCII (with optional dashes): {name}" + ); + assert!( + !description.is_empty(), + "every palette command needs a description: {name}" + ); + } + } + + #[test] + fn palette_includes_locally_handled_commands() { + // Sanity check: every command exposed in the palette must be one + // the codegraff dispatcher (`handle_enter`) actually routes + // locally. If you add a command here, also wire it in + // `handle_enter`, otherwise selecting it from the palette will + // silently dispatch to the LLM as a chat message. + let must_be_present = [ + "act", "agent", "clone", "commit", "commit-preview", "compact", "config", + "config-commit-model", "config-edit", "config-model", "config-reasoning-effort", + "config-reload", "config-suggest-model", "connect", "conversation", + "conversation-rename", "copy", "dump", "fast", "help", "image", "index", "info", + "login", "logout", "logs", "model", "models", "new", "plan", "reasoning-effort", + "rename", "sage", "skill", "suggest", "tools", "update", "usage", "workflow", + "workspace-info", "workspace-init", "workspace-status", "workspace-sync", + ]; + for name in &must_be_present { + assert!( + PALETTE_COMMANDS.iter().any(|(n, _)| n == name), + "expected palette to include `/{name}` (it is handled by handle_enter)" + ); + } + } + + #[test] + fn palette_excludes_repl_only_commands() { + for name in &["exit", "edit", "retry"] { + assert!( + !PALETTE_COMMANDS.iter().any(|(n, _)| n == name), + "REPL-only command '{name}' must be excluded from the palette" + ); + } + } + #[test] + fn palette_lines_render_query_and_selected_marker() { + let state = CommandPaletteState { + query: "mod".to_string(), + selected_index: 0, + }; + let matches = filter_palette_commands(&state.query); + let lines = command_palette_lines(&state, &matches, 80); + assert!(!lines.is_empty(), "palette should render at least one line"); + + let rendered: Vec = lines.iter().cloned().map(render_line).collect(); + + // First line shows the query prompt with leading slash. + assert!( + rendered[0].starts_with("/mod"), + "palette prompt should echo /mod, got {:?}", + rendered[0] + ); + + // Some line should contain the selected marker '>' followed by /. + let has_marker = rendered + .iter() + .any(|line| line.trim_start().starts_with('>')); + assert!(has_marker, "expected a selected '>' marker in palette"); + } + + #[test] + fn palette_lines_show_no_match_hint_for_zero_results() { + let state = CommandPaletteState { + query: "zzzzqqqqzz".to_string(), + selected_index: 0, + }; + let matches = filter_palette_commands(&state.query); + assert!(matches.is_empty()); + let lines = command_palette_lines(&state, &matches, 80); + let rendered: Vec = lines.iter().cloned().map(render_line).collect(); + assert!( + rendered.iter().any(|line| line.contains("No matching")), + "missing no-match hint, got {rendered:?}" + ); + } #[test] fn connect_intent_uses_login_dialog_copy() { let fixture = ConnectIntent::Login;