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
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:
parent
e41a641c94
commit
b1aac41a56
3 changed files with 349 additions and 23 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
src/tui/mod.rs
157
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<String> =
|
||||
app.persona_items.iter().map(|p| p.name.clone()).collect();
|
||||
match app.set_persona_by_name(target) {
|
||||
Ok(persona_name) => {
|
||||
app.add_system_message(format!(
|
||||
"Persona '{target}' not found. Available personas:\n {}",
|
||||
available.join(", ")
|
||||
"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 {
|
||||
|
|
|
|||
114
src/tui/ui.rs
114
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 <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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue