fix(provider): keep picker selection visible

Make the /provider modal size itself to the provider list when space allows,
  and scroll the rendered list when the selected provider moves past the visible
  rows.

  Also make the selected row use a continuous, subtle highlight so the current
  selection remains visible without looking disconnected.

  Adds regression coverage for bottom providers, initial bottom selection, full
  height rendering, and selected-row highlighting.
This commit is contained in:
reidliu41
2026-05-27 10:24:23 +08:00
parent cdec3e8192
commit 5ed741dc8c
+128 -16
View File
@@ -21,7 +21,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Widget},
};
@@ -116,6 +116,28 @@ impl ProviderPickerView {
}
}
fn visible_start(&self, visible_rows: usize) -> usize {
if visible_rows == 0 {
return 0;
}
let max_start = self.providers.len().saturating_sub(visible_rows);
self.selected_idx
.saturating_add(1)
.saturating_sub(visible_rows)
.min(max_start)
}
fn selected_row_style(fg: Color) -> Style {
Style::default()
.fg(fg)
.bg(palette::SURFACE_ELEVATED)
.add_modifier(Modifier::BOLD)
}
fn selected_row_bg_style() -> Style {
Style::default().bg(palette::SURFACE_ELEVATED)
}
fn render_list(&self, area: Rect, buf: &mut Buffer) {
let outer = Block::default()
.title(Line::from(Span::styled(
@@ -138,39 +160,64 @@ impl ProviderPickerView {
let inner = outer.inner(area);
outer.render(area, buf);
let mut lines: Vec<Line> = Vec::with_capacity(self.providers.len());
for (idx, (provider, has_key)) in self.providers.iter().enumerate() {
let visible_rows = usize::from(inner.height);
let visible_start = self.visible_start(visible_rows);
let mut lines: Vec<Line> = Vec::with_capacity(visible_rows);
for (idx, (provider, has_key)) in self
.providers
.iter()
.enumerate()
.skip(visible_start)
.take(visible_rows)
{
let is_selected = idx == self.selected_idx;
let is_active = *provider == self.active_provider;
let arrow = if is_selected { "" } else { " " };
let active_dot = if is_active { " *" } else { " " };
let label_style = if is_selected {
let spacer_style = if is_selected {
Self::selected_row_bg_style()
} else {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
.add_modifier(Modifier::BOLD)
};
let label_style = if is_selected {
Self::selected_row_style(palette::TEXT_PRIMARY)
} else {
Style::default().fg(palette::TEXT_PRIMARY)
};
let hint_style = if is_selected {
Style::default()
.fg(palette::SELECTION_TEXT)
.bg(palette::SELECTION_BG)
let hint_fg = if *has_key {
palette::TEXT_MUTED
} else {
palette::STATUS_WARNING
};
Self::selected_row_style(hint_fg)
} else if *has_key {
Style::default().fg(palette::TEXT_MUTED)
} else {
Style::default().fg(palette::STATUS_WARNING)
};
let hint = Self::provider_hint(*provider, *has_key);
lines.push(Line::from(vec![
Span::raw(" "),
let mut line = Line::from(vec![
Span::styled(" ", spacer_style),
Span::styled(arrow, label_style),
Span::raw(" "),
Span::styled(" ", spacer_style),
Span::styled(provider.display_name().to_string(), label_style),
Span::styled(active_dot, label_style),
Span::raw(" "),
Span::styled(" ", spacer_style),
Span::styled(hint, hint_style),
]));
]);
if is_selected {
line.style = Self::selected_row_bg_style();
let target_width = usize::from(inner.width);
let line_width = line.width();
if line_width < target_width {
line.spans.push(Span::styled(
" ".repeat(target_width - line_width),
Self::selected_row_bg_style(),
));
}
}
lines.push(line);
}
Paragraph::new(lines).render(inner, buf);
}
@@ -347,7 +394,7 @@ impl ModalView for ProviderPickerView {
fn render(&self, area: Rect, buf: &mut Buffer) {
let popup_width = 64.min(area.width.saturating_sub(4)).max(40);
let popup_height = match self.stage {
Stage::List => 12,
Stage::List => (self.providers.len() as u16).saturating_add(2),
Stage::KeyEntry => 10,
}
.min(area.height.saturating_sub(4))
@@ -388,6 +435,16 @@ mod tests {
panic!("provider {provider:?} not found in picker");
}
fn render_text(picker: &ProviderPickerView, width: u16, height: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
(0..height)
.map(|y| (0..width).map(|x| buf[(x, y)].symbol()).collect::<String>())
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn picker_lists_all_providers() {
let config = Config::default();
@@ -529,4 +586,59 @@ mod tests {
}
assert_eq!(picker.api_key_input, "abcdef");
}
#[test]
fn small_list_render_keeps_selected_provider_visible_after_down_navigation() {
let config = Config::default();
let mut picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
move_to_provider(&mut picker, ApiProvider::Ollama);
let rendered = render_text(&picker, 80, 12);
assert!(rendered.contains("Ollama"));
assert!(!rendered.contains("DeepSeek *"));
}
#[test]
fn small_list_render_keeps_initial_active_provider_visible() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Ollama, &config);
let rendered = render_text(&picker, 80, 12);
assert!(rendered.contains("Ollama *"));
}
#[test]
fn tall_list_render_shows_all_providers_without_scrolling() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let rendered = render_text(&picker, 80, 20);
assert!(rendered.contains("DeepSeek *"));
assert!(rendered.contains("Ollama"));
}
#[test]
fn selected_provider_row_uses_strong_highlight() {
let config = Config::default();
let picker = ProviderPickerView::new(ApiProvider::Deepseek, &config);
let area = Rect::new(0, 0, 80, 20);
let mut buf = Buffer::empty(area);
picker.render(area, &mut buf);
let highlighted_cells = area
.positions()
.filter(|position| {
let cell = &buf[*position];
cell.bg == palette::SURFACE_ELEVATED
})
.count();
assert!(
highlighted_cells >= 32,
"selected provider row should use a visible continuous highlight"
);
}
}