use super::panel::wrap_text;
use crate::{
modes::interactive::{
app::App,
settings_selector::{MAX_VISIBLE, SubmenuKind, SubmenuState},
},
utils,
};
use ratatui::{
Frame,
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
};
pub fn selector_height(app: &App, width: usize) -> u16 {
let s = &app.settings_selector;
if !s.active {
return 0;
}
super::panel::trim_panel_blanks(settings_lines(app, width)).len() as u16 + 4
}
fn settings_lines(app: &App, width: usize) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
if let Some(sub) = &app.settings_selector.submenu {
render_submenu(&mut lines, app, sub, width);
} else {
render_main_list(&mut lines, app, width);
}
lines
}
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let width = area.width as usize;
let border = app.theme.style("border", "#5f87ff");
let lines = settings_lines(app, width);
super::panel::render_panel_lines(frame, area, width, border, lines);
}
fn render_submenu(lines: &mut Vec<Line<'static>>, app: &App, sub: &SubmenuState, width: usize) {
let accent = app.theme.style("accent", "#8abeb7");
let muted = app.theme.style("muted", "#808080");
let bold = Style::default().add_modifier(Modifier::BOLD);
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
sub.title.clone(),
bold.patch(accent),
)));
lines.push(Line::from(""));
if !sub.description.is_empty() {
for chunk in wrap_text(&sub.description, width.saturating_sub(4)) {
lines.push(Line::from(Span::styled(format!(" {}", chunk), muted)));
}
lines.push(Line::from(""));
}
let total = sub.options.len();
let max = total.min(MAX_VISIBLE);
let start = sub
.selected
.saturating_sub(max / 2)
.min(total.saturating_sub(max));
let end = (start + max).min(total);
for i in start..end {
let opt = &sub.options[i];
let selected = i == sub.selected;
let prefix = if selected { "→ " } else { " " };
let label_style = if selected { accent } else { Style::default() };
let text = match sub.kind {
SubmenuKind::Toggle => {
format!("[{}] {}", if opt.checked { "x" } else { " " }, opt.value)
}
SubmenuKind::Select => opt.label.clone(),
};
let mut spans = vec![Span::styled(format!("{}{}", prefix, text), label_style)];
if !opt.description.is_empty() && width > 40 {
let used = 4 + utils::display::display_width(&text);
let remain = width.saturating_sub(used);
if remain > 10 {
let (d, _) = utils::display::truncate_display(&opt.description, remain);
spans.push(Span::styled(format!(" {}", d), muted));
}
}
lines.push(Line::from(spans));
}
lines.push(Line::from(""));
let hint = match sub.kind {
SubmenuKind::Toggle => " Space/Enter to toggle · Esc to go back",
SubmenuKind::Select => " Enter to select · Esc to go back",
};
lines.push(Line::from(Span::styled(hint, muted)));
lines.push(Line::from(""));
}
fn render_main_list(lines: &mut Vec<Line<'static>>, app: &App, width: usize) {
let s = &app.settings_selector;
let muted = app.theme.style("muted", "#808080");
let (view, _) = s.filter.visible_window(width.saturating_sub(4));
let (f, _) = utils::display::truncate_display(&view, width.saturating_sub(4));
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("> ", Style::default()),
Span::styled(f, Style::default()),
]));
lines.push(Line::from(""));
let total = s.filtered.len();
if total == 0 {
lines.push(Line::from(Span::styled(" No matching settings", muted)));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" Type to search · Enter/Space to change · Esc to cancel",
muted,
)));
lines.push(Line::from(""));
return;
}
render_setting_items(lines, app, width);
}
fn render_setting_items(lines: &mut Vec<Line<'static>>, app: &App, width: usize) {
let s = &app.settings_selector;
let accent = app.theme.style("accent", "#8abeb7");
let muted = app.theme.style("muted", "#808080");
let total = s.filtered.len();
let max_label = s
.items
.iter()
.map(|it| utils::display::display_width(&it.label))
.max()
.unwrap_or(0)
.min(30);
let start = s
.selected
.saturating_sub(MAX_VISIBLE / 2)
.min(total.saturating_sub(MAX_VISIBLE));
let end = (start + MAX_VISIBLE).min(total);
for i in start..end {
let item = &s.items[s.filtered[i]];
let selected = i == s.selected;
let prefix = if selected { "→ " } else { " " };
let label_padded = item.label.clone()
+ &" ".repeat(max_label.saturating_sub(utils::display::display_width(&item.label)));
let label_style = if selected { accent } else { Style::default() };
let used = 2 + max_label + 2;
let value_max = width.saturating_sub(used).saturating_sub(2);
let (value, _) = utils::display::truncate_display(&item.current_value, value_max);
let value_style = if selected { accent } else { muted };
lines.push(Line::from(vec![
Span::styled(prefix.to_string() + &label_padded, label_style),
Span::styled(" ", Style::default()),
Span::styled(value, value_style),
]));
}
if start > 0 || end < total {
lines.push(Line::from(Span::styled(
format!(" ({}/{})", s.selected + 1, total),
muted,
)));
}
lines.push(Line::from(""));
if let Some(&idx) = s.filtered.get(s.selected) {
let desc = &s.items[idx].description;
if !desc.is_empty() {
for chunk in wrap_text(desc, width.saturating_sub(4)) {
lines.push(Line::from(Span::styled(format!(" {}", chunk), muted)));
}
lines.push(Line::from(""));
}
}
lines.push(Line::from(Span::styled(
" Type to search · Enter/Space to change · Esc to cancel",
muted,
)));
lines.push(Line::from(""));
}
#[cfg(test)]
mod tests {
use super::*;
fn test_agent() -> crate::modes::interactive::agent_actor::WorkerHandle {
let _ad = crate::test_support::AgentDirGuard::temp();
crate::modes::interactive::agent_actor::WorkerHandle::null()
}
fn open_sel_on(app: &mut App, _agent: &crate::modes::interactive::agent_actor::WorkerHandle) {
let (show_thinking, theme_name, autocomplete_max) = (
app.show_thinking,
app.theme.name.clone(),
app.autocomplete_max_visible,
);
app.settings_selector.open(
show_thinking,
&theme_name,
autocomplete_max,
app.thinking_level.as_deref().unwrap_or("off"),
);
}
#[test]
fn height_includes_search_list_desc_and_hint() {
let _ad = crate::test_support::AgentDirGuard::temp();
let agent = test_agent();
let mut app = App::new();
open_sel_on(&mut app, &agent);
let h = selector_height(&app, 80) as usize;
assert!(h >= 9 + 8, "height too small: {}", h);
app.settings_selector.items[0].description = "x".repeat(200);
let h2 = selector_height(&app, 80) as usize;
assert!(h2 > h, "描述行应增加高度: {} > {}", h2, h);
}
#[test]
fn render_layout_matches_pi_settings_list() {
let _ad = crate::test_support::AgentDirGuard::temp();
let agent = test_agent();
let mut app = App::new();
open_sel_on(&mut app, &agent);
app.settings_selector.selected = 0; let h = selector_height(&app, 80);
use ratatui::backend::TestBackend;
let backend = TestBackend::new(80, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| {
let area = Rect::new(0, 0, 80, h);
render(f, area, &app);
})
.unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| {
(0..80u16)
.map(|x| {
buf.cell((x, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
};
assert!(row(2).starts_with("> "), "search row: {:?}", row(2));
let r4 = row(4);
assert!(
r4.trim_start().starts_with("→ Auto-compact"),
"row4: {:?}",
r4
);
let mut last_found = false;
for y in 3..20u16 {
if row(y).contains("Retry max attempts") {
last_found = true;
break;
}
}
assert!(last_found, "未找到最后可见设置项");
let mut auto_row: Option<String> = None;
for y in 3..20u16 {
let r = row(y);
if r.trim_start().starts_with(" Auto-compact")
|| r.trim_start().starts_with("→ Auto-compact")
{
auto_row = Some(r);
break;
}
}
let v = auto_row.expect("Auto-compact 行");
let trimmed: String = v
.trim_end()
.chars()
.rev()
.take(4)
.collect::<String>()
.chars()
.rev()
.collect();
assert_eq!(trimmed, "true", "value on right: {:?}", trimmed);
let mut desc_found = false;
for y in 3..20u16 {
if row(y).contains("Automatically compact context") {
desc_found = true;
break;
}
}
assert!(desc_found, "未找到描述");
assert!(
(1..h)
.any(|y| row(y).contains("Type to search · Enter/Space to change · Esc to cancel")),
"hint missing"
);
}
#[test]
fn toggle_submenu_renders_extension_style_checkboxes() {
let _ad = crate::test_support::AgentDirGuard::temp();
let agent = test_agent();
let mut app = App::new();
open_sel_on(&mut app, &agent);
app.settings_selector.submenu = Some(SubmenuState {
title: "Built-in tools".to_string(),
description: String::new(),
kind: SubmenuKind::Toggle,
options: vec![
crate::modes::interactive::settings_selector::SubmenuOption {
value: "read".to_string(),
label: "read".to_string(),
description: "Read files and images".to_string(),
checked: true,
},
crate::modes::interactive::settings_selector::SubmenuOption {
value: "grep".to_string(),
label: "grep".to_string(),
description: "Search file contents".to_string(),
checked: false,
},
],
selected: 1,
item_index: 0,
});
let w = 80u16;
let h = selector_height(&app, w as usize);
use ratatui::backend::TestBackend;
let backend = TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| render(f, Rect::new(0, 0, w, h), &app))
.unwrap();
let buf = terminal.backend().buffer();
let rows: Vec<String> = (0..h)
.map(|y| {
(0..w)
.map(|x| {
buf.cell((x, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect()
})
.collect();
let text = rows.join("\n");
assert!(text.contains("Built-in tools"), "{text}");
assert!(text.contains("[x] read"), "勾选态: {text}");
assert!(text.contains("→ [ ] grep"), "未勾选项带 → 前缀: {text}");
assert!(text.contains("Search file contents"), "工具说明: {text}");
assert!(
text.contains("Space/Enter to toggle · Esc to go back"),
"底部提示: {text}"
);
}
#[test]
fn selector_has_exactly_one_blank_line_around_content() {
let _ad = crate::test_support::AgentDirGuard::temp();
let agent = test_agent();
let mut app = App::new();
open_sel_on(&mut app, &agent);
let w = 80u16;
let h = selector_height(&app, w as usize);
use ratatui::backend::TestBackend;
let backend = TestBackend::new(w, h);
let mut terminal = ratatui::Terminal::new(backend).unwrap();
terminal
.draw(|f| render(f, Rect::new(0, 0, w, h), &app))
.unwrap();
let buf = terminal.backend().buffer();
let row = |y: u16| -> String {
(0..w)
.map(|x| {
buf.cell((x, y))
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect()
};
assert!(row(0).starts_with('─'), "上分割线: {:?}", row(0));
assert!(row(h - 1).starts_with('─'), "下分割线: {:?}", row(h - 1));
let inner: Vec<String> = (1..h - 1).map(row).collect();
let lead = inner.iter().take_while(|r| r.trim().is_empty()).count();
let trail = inner
.iter()
.rev()
.take_while(|r| r.trim().is_empty())
.count();
assert_eq!(lead, 1, "顶部恰好 1 行空行");
assert_eq!(trail, 1, "底部恰好 1 行空行");
}
}