diff --git a/src/tui/app.rs b/src/tui/app.rs index 5fa7954..a8b459d 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -41,6 +41,7 @@ pub enum TuiMode { PipelineSelect, SessionSelect, ThemeSelect, + Settings, } #[derive(Debug, Clone)] @@ -110,6 +111,10 @@ pub const ALL_COMMANDS: &[CommandSuggestion] = &[ name: "/persona", description: "Select active persona or pass name", }, + CommandSuggestion { + name: "/p", + description: "Quick switch persona (e.g. /p coder, /p next)", + }, CommandSuggestion { name: "/pipeline", description: "Select active pipeline or pass name", @@ -267,6 +272,9 @@ pub struct AppState { // Options pub allow_stealth: bool, pub escalate: bool, + pub complexity_threshold: u8, + pub tool_execution_enabled: bool, + pub selected_setting_idx: usize, // UX & Theme pub expand_tool_outputs: bool, @@ -283,6 +291,7 @@ impl AppState { daily_limit: usize, config_dir: PathBuf, initial_theme: String, + complexity_threshold: u8, ) -> Self { let mut persona_items = Vec::new(); // Item 0 is always the smart Auto-Router @@ -334,6 +343,9 @@ impl AppState { tokens_out: 0, allow_stealth: false, escalate: false, + complexity_threshold, + tool_execution_enabled: true, + selected_setting_idx: 0, expand_tool_outputs: false, current_theme: initial_theme, selected_suggestion_idx: 0, @@ -388,6 +400,59 @@ impl AppState { self.selected_persona_idx == 0 } + pub fn cycle_persona(&mut self) { + if !self.persona_items.is_empty() { + self.selected_persona_idx = (self.selected_persona_idx + 1) % self.persona_items.len(); + self.active_pipeline_for_prompt = None; + } + } + + pub fn set_persona_by_name(&mut self, query: &str) -> Result { + let q = query.trim().to_lowercase(); + if q.is_empty() { + return Err("Persona name cannot be empty".to_string()); + } + + // Exact match + if let Some(pos) = self + .persona_items + .iter() + .position(|p| p.name.to_lowercase() == q) + { + self.selected_persona_idx = pos; + self.active_pipeline_for_prompt = None; + return Ok(self.current_persona_name().to_string()); + } + + // Prefix match + if let Some(pos) = self + .persona_items + .iter() + .position(|p| p.name.to_lowercase().starts_with(&q)) + { + self.selected_persona_idx = pos; + self.active_pipeline_for_prompt = None; + return Ok(self.current_persona_name().to_string()); + } + + // Substring match + if let Some(pos) = self + .persona_items + .iter() + .position(|p| p.name.to_lowercase().contains(&q)) + { + self.selected_persona_idx = pos; + self.active_pipeline_for_prompt = None; + return Ok(self.current_persona_name().to_string()); + } + + let names: Vec = self.persona_items.iter().map(|p| p.name.clone()).collect(); + Err(format!( + "No persona matching '{query}'. Available: {}", + names.join(", ") + )) + } + pub fn log_working_event(&mut self, text: &str) { let now = chrono::Local::now().format("%H:%M:%S").to_string(); self.workings.events.push(format!("{now} {text}")); @@ -772,6 +837,7 @@ mod tests { 50, PathBuf::from("/tmp/brokecrew_test"), "opencode".to_string(), + 7, ); assert!(app.is_start_screen()); @@ -798,6 +864,7 @@ mod tests { 50, PathBuf::from("/tmp/brokecrew_test"), "opencode".to_string(), + 7, ); assert_eq!(app.current_theme, "opencode"); app.cycle_theme(); @@ -824,6 +891,7 @@ mod tests { 50, temp_dir.path().to_path_buf(), "opencode".to_string(), + 7, ); app.add_user_message("Hello from user".to_string()); app.start_assistant_message("coder", "qwen-coder", Tier::Free); @@ -838,4 +906,33 @@ mod tests { let json_path = app.export_active_session_json().expect("export json"); assert!(json_path.exists()); } + + #[test] + fn test_persona_quick_switch() { + let personas = vec![ + ("coder".to_string(), "coding assistant".to_string()), + ("reviewer".to_string(), "review assistant".to_string()), + ]; + let mut app = AppState::new( + personas, + vec![], + 20, + 50, + PathBuf::from("/tmp/brokecrew_test"), + "opencode".to_string(), + 7, + ); + assert_eq!(app.current_persona_name(), "auto"); + + assert_eq!(app.set_persona_by_name("c").unwrap(), "coder"); + assert_eq!(app.current_persona_name(), "coder"); + + assert_eq!(app.set_persona_by_name("rev").unwrap(), "reviewer"); + assert_eq!(app.current_persona_name(), "reviewer"); + + assert_eq!(app.set_persona_by_name("auto").unwrap(), "auto"); + assert_eq!(app.current_persona_name(), "auto"); + + assert!(app.set_persona_by_name("nonexistent").is_err()); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index a9ba236..395801b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -132,6 +132,7 @@ pub async fn run_tui( daily_limit, config.config_dir.clone(), config.theme.clone(), + config.complexity_threshold, ); app.allow_stealth = allow_stealth || config.allow_stealth; app.escalate = escalate; @@ -561,11 +562,126 @@ async fn handle_key_event( _ => {} }, + TuiMode::Settings => match key.code { + KeyCode::Esc => { + app.mode = TuiMode::Normal; + } + KeyCode::Up | KeyCode::Char('k') => { + app.selected_setting_idx = app.selected_setting_idx.saturating_sub(1); + } + KeyCode::Down | KeyCode::Char('j') => { + if app.selected_setting_idx + 1 < 7 { + app.selected_setting_idx += 1; + } + } + KeyCode::Enter | KeyCode::Char(' ') => match app.selected_setting_idx { + 0 => { + app.cycle_theme(); + let theme_name = app.current_theme.clone(); + let _ = AppConfig::save_theme(&app.config_dir, &theme_name); + app.log_working_event(&format!("[settings] theme switched to {theme_name}")); + } + 1 => { + app.allow_stealth = !app.allow_stealth; + app.log_working_event(&format!( + "[settings] stealth models: {}", + app.allow_stealth + )); + } + 2 => { + app.escalate = !app.escalate; + app.log_working_event(&format!( + "[settings] complexity escalate: {}", + app.escalate + )); + } + 3 => { + app.complexity_threshold = if app.complexity_threshold >= 10 { + 1 + } else { + app.complexity_threshold + 1 + }; + app.log_working_event(&format!( + "[settings] complexity threshold: {}", + app.complexity_threshold + )); + } + 4 => { + app.tool_execution_enabled = !app.tool_execution_enabled; + app.log_working_event(&format!( + "[settings] tool execution: {}", + app.tool_execution_enabled + )); + } + 5 => { + app.expand_tool_outputs = !app.expand_tool_outputs; + app.log_working_event(&format!( + "[settings] expand tool output: {}", + app.expand_tool_outputs + )); + } + 6 => { + app.cycle_persona(); + let persona = app.current_persona_name().to_string(); + app.log_working_event(&format!("[settings] persona set to {persona}")); + } + _ => {} + }, + KeyCode::Left | KeyCode::Char('h') => match app.selected_setting_idx { + 0 => { + let current_pos = app::THEMES + .iter() + .position(|(name, _)| *name == app.current_theme) + .unwrap_or(0); + let prev_pos = if current_pos == 0 { + app::THEMES.len() - 1 + } else { + current_pos - 1 + }; + app.current_theme = app::THEMES[prev_pos].0.to_string(); + app.selected_theme_idx = prev_pos; + let theme_name = app.current_theme.clone(); + let _ = AppConfig::save_theme(&app.config_dir, &theme_name); + app.log_working_event(&format!("[settings] theme switched to {theme_name}")); + } + 3 if app.complexity_threshold > 1 => { + app.complexity_threshold -= 1; + app.log_working_event(&format!( + "[settings] complexity threshold: {}", + app.complexity_threshold + )); + } + _ => {} + }, + KeyCode::Right | KeyCode::Char('l') => match app.selected_setting_idx { + 0 => { + app.cycle_theme(); + let theme_name = app.current_theme.clone(); + let _ = AppConfig::save_theme(&app.config_dir, &theme_name); + app.log_working_event(&format!("[settings] theme switched to {theme_name}")); + } + 3 if app.complexity_threshold < 10 => { + app.complexity_threshold += 1; + app.log_working_event(&format!( + "[settings] complexity threshold: {}", + app.complexity_threshold + )); + } + _ => {} + }, + _ => {} + }, + TuiMode::Normal => { // Mode switching and shortcuts if key.modifiers.contains(KeyModifiers::CONTROL) { app.esc_pressed_at = None; match key.code { + KeyCode::Char(',') => { + app.selected_setting_idx = 0; + app.mode = TuiMode::Settings; + return false; + } KeyCode::Char('p') => { app.mode = TuiMode::PipelineSelect; return false; @@ -961,13 +1077,8 @@ async fn handle_slash_command( app.mode = TuiMode::ThemeSelect; } "/settings" => { - app.add_system_message(format!( - "Runtime Settings:\n Stealth Models: {} (toggle with /stealth)\n Complexity Escalate: {} (toggle with /escalate)\n Active Persona: {}\n Active Pipeline: {}", - if app.allow_stealth { "enabled" } else { "disabled" }, - if app.escalate { "enabled" } else { "disabled" }, - app.current_persona_name(), - app.active_pipeline_for_prompt.as_deref().unwrap_or("none") - )); + app.selected_setting_idx = 0; + app.mode = TuiMode::Settings; } "/stealth" => { app.allow_stealth = !app.allow_stealth; @@ -990,26 +1101,25 @@ async fn handle_slash_command( "/clear" => { app.clear_chat(); } - "/persona" | "/personas" => { + "/p" | "/persona" | "/personas" => { if let Some(target) = parts.get(1) { - let target_lower = target.to_lowercase(); - if let Some(pos) = app - .persona_items - .iter() - .position(|p| p.name.to_lowercase() == target_lower) - { - app.selected_persona_idx = pos; - app.active_pipeline_for_prompt = None; + if target.eq_ignore_ascii_case("next") { + app.cycle_persona(); let persona_name = app.current_persona_name().to_string(); app.add_system_message(format!("Switched active persona to: {persona_name}")); app.log_working_event(&format!("[persona] switched to {persona_name}")); } else { - let available: Vec = - app.persona_items.iter().map(|p| p.name.clone()).collect(); - app.add_system_message(format!( - "Persona '{target}' not found. Available personas:\n {}", - available.join(", ") - )); + match app.set_persona_by_name(target) { + Ok(persona_name) => { + app.add_system_message(format!( + "Switched active persona to: {persona_name}" + )); + app.log_working_event(&format!("[persona] switched to {persona_name}")); + } + Err(e) => { + app.add_system_message(format!("! {e}")); + } + } } } else { app.mode = TuiMode::PersonaSelect; @@ -1067,6 +1177,7 @@ fn spawn_dispatch_task( let direct_persona = app.current_persona_name().to_string(); let allow_stealth = app.allow_stealth; let escalate = app.escalate; + let tool_execution_enabled = app.tool_execution_enabled; let persona_registry = config.persona_registry.clone(); let pipelines = config.pipelines.clone(); @@ -1319,7 +1430,11 @@ fn spawn_dispatch_task( match res { Ok(disp) => { - let commands = ToolEngine::extract_command_calls(&disp.reply); + let commands = if tool_execution_enabled { + ToolEngine::extract_command_calls(&disp.reply) + } else { + Vec::new() + }; if !commands.is_empty() { let mut tool_results_summary = String::new(); for cmd in commands { diff --git a/src/tui/ui.rs b/src/tui/ui.rs index 124c64f..2bcb0fe 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -138,6 +138,7 @@ pub fn render(frame: &mut Frame, app: &mut AppState) { TuiMode::PipelineSelect => render_pipeline_popup(frame, app, &palette), TuiMode::SessionSelect => render_session_popup(frame, app, &palette), TuiMode::ThemeSelect => render_theme_popup(frame, app, &palette), + TuiMode::Settings => render_settings_popup(frame, app, &palette), TuiMode::Normal => {} } } @@ -1548,6 +1549,119 @@ fn render_theme_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) frame.render_widget(list, area); } +fn render_settings_popup(frame: &mut Frame, app: &AppState, palette: &ThemePalette) { + let area = centered_rect(68, 62, frame.area()); + frame.render_widget(Clear, area); + + let setting_items = [ + ( + "● Active Theme", + format!("{:<14} (enter/left/right cycle)", app.current_theme), + ), + ( + "● Stealth Candidates", + if app.allow_stealth { + "[ENABLED] (experimental free models included)".to_string() + } else { + "[DISABLED] (only catalog models)".to_string() + }, + ), + ( + "● Complexity Escalation", + if app.escalate { + "[ENABLED] (escalate to paid on heavy tasks)".to_string() + } else { + "[DISABLED] (always prefer free models)".to_string() + }, + ), + ("● Complexity Threshold", { + let filled = (app.complexity_threshold as usize).min(10); + let empty = 10 - filled; + format!( + "[{}{}] {} / 10 (left/right adjust)", + "■".repeat(filled), + "□".repeat(empty), + app.complexity_threshold + ) + }), + ( + "● Local Tool Execution", + if app.tool_execution_enabled { + "[AUTO-EXECUTE] (run commands locally)".to_string() + } else { + "[DISABLED] (commands rendered only)".to_string() + }, + ), + ( + "● Tool Output View", + if app.expand_tool_outputs { + "[EXPANDED] (full stdout/stderr shown)".to_string() + } else { + "[COLLAPSED] (compact with ctrl+o expand)".to_string() + }, + ), + ( + "● Active Persona", + format!("{:<14} (enter/space to cycle)", app.current_persona_name()), + ), + ]; + + let items: Vec = setting_items + .iter() + .enumerate() + .map(|(idx, (name, val))| { + let is_selected = idx == app.selected_setting_idx; + let prefix = if is_selected { "→ " } else { " " }; + + let line = Line::from(vec![ + Span::styled( + prefix, + if is_selected { + Style::default().fg(palette.accent).bold() + } else { + Style::default().fg(Color::DarkGray) + }, + ), + Span::styled( + format!("{:<26} ", name), + if is_selected { + Style::default().fg(palette.accent).bold() + } else { + Style::default().fg(palette.text_bright) + }, + ), + Span::styled( + val.clone(), + if is_selected { + Style::default().fg(palette.text_bright).bold() + } else { + Style::default().fg(palette.text_muted) + }, + ), + ]); + + if is_selected { + ListItem::new(line).style(Style::default().bg(Color::Rgb(32, 38, 54))) + } else { + ListItem::new(line) + } + }) + .collect(); + + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette.accent)) + .title(Span::styled( + " settings [up/down navigate, enter/space toggle, left/right adjust, esc close] ", + Style::default().fg(palette.accent).bold(), + )), + ); + + frame.render_widget(list, area); +} + fn get_persona_color(name: &str) -> Color { match name.to_lowercase().as_str() { "coder" => Color::LightGreen,