feat: interactive /settings modal and quick /p persona command with fuzzy prefix matching
Some checks are pending
CI / Test (macos-latest) (push) Waiting to run
CI / Test (ubuntu-latest) (push) Waiting to run
CI / Test (windows-latest) (push) Waiting to run

This commit is contained in:
katR 2026-09-30 07:02:17 +05:30
parent e41a641c94
commit b1aac41a56
3 changed files with 349 additions and 23 deletions

View file

@ -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<String, String> {
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<String> = 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());
}
}

View file

@ -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<String> =
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 {

View file

@ -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 <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<ListItem> = 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,