Skip to content

Commit 27ae396

Browse files
committed
fix: keep onboarding footer visible
1 parent c02e270 commit 27ae396

3 files changed

Lines changed: 136 additions & 19 deletions

File tree

crates/tui/src/inline_onboarding_tests.rs

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,26 @@ use crate::tui::frame_requester::FrameRequester;
2626
fn onboarding_widget_with_available_model(
2727
model: Model,
2828
cwd: PathBuf,
29+
) -> (ChatWidget, mpsc::UnboundedReceiver<AppEvent>) {
30+
onboarding_widget_with_models(vec![model.clone()], Some(model), cwd)
31+
}
32+
33+
fn onboarding_widget_with_models(
34+
models: Vec<Model>,
35+
initial_model: Option<Model>,
36+
cwd: PathBuf,
2937
) -> (ChatWidget, mpsc::UnboundedReceiver<AppEvent>) {
3038
let (app_event_tx, app_event_rx) = mpsc::unbounded_channel();
3139
let widget = ChatWidget::new_with_app_event(ChatWidgetInit {
3240
frame_requester: FrameRequester::test_dummy(),
3341
app_event_tx: AppEventSender::new(app_event_tx),
34-
initial_session: TuiSessionState::new(cwd, Some(model.clone())),
42+
initial_session: TuiSessionState::new(cwd, initial_model),
3543
initial_reasoning_effort_selection: None,
3644
initial_permission_preset: devo_protocol::PermissionPreset::Default,
3745
initial_user_message: None,
3846
enhanced_keys_supported: true,
3947
is_first_run: false,
40-
available_models: vec![model],
48+
available_models: models,
4149
saved_models: Vec::new(),
4250
show_model_onboarding: true,
4351
exit_after_onboarding: false,
@@ -113,9 +121,42 @@ fn first_run_onboarding_starts_with_logo_and_hides_composer() {
113121

114122
let rows = rendered_rows(&widget, 100, 24).join("\n");
115123
assert!(rows.contains("Choose model profile"));
124+
assert!(rows.contains("Enter select · Esc cancel"));
116125
assert!(!rows.contains("Complete onboarding to start chatting"));
117126
assert!(!rows.contains("SHIFT+TAB switch"));
118127
assert!(widget.desired_height(100) < u16::MAX);
128+
129+
let rows = rendered_rows(&widget, 100, widget.desired_height(100)).join("\n");
130+
assert!(rows.contains("Enter select · Esc cancel"));
131+
}
132+
133+
#[test]
134+
fn model_selection_footer_stays_visible_in_short_viewport() {
135+
let cwd = std::env::current_dir().expect("current directory is available");
136+
let models = (0..12)
137+
.map(|idx| Model {
138+
slug: format!("model-{idx:02}"),
139+
display_name: format!("Model {idx:02} Display Name"),
140+
..Model::default()
141+
})
142+
.collect::<Vec<_>>();
143+
let initial_model = models.first().cloned();
144+
let (mut widget, _app_event_rx) = onboarding_widget_with_models(models, initial_model, cwd);
145+
146+
for _ in 0..10 {
147+
widget.handle_key_event(press_key(KeyCode::Down));
148+
}
149+
150+
let rows = rendered_rows(&widget, 80, 8).join("\n");
151+
assert!(
152+
rows.contains("model-10"),
153+
"expected selected model in:\n{rows}"
154+
);
155+
assert!(
156+
rows.contains("Enter select · Esc cancel"),
157+
"expected fixed onboarding footer in:\n{rows}"
158+
);
159+
assert!(!rows.contains("Complete onboarding to start chatting"));
119160
}
120161

121162
#[test]

crates/tui/src/onboarding_viewport.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,45 @@ pub(crate) fn render_lines_with_anchor(
4444
render_scrolled_paragraph(paragraph, total_rows, scroll_offset, area, buf);
4545
}
4646

47+
pub(crate) fn render_lines_with_fixed_footer(
48+
body_lines: Vec<Line<'static>>,
49+
footer_lines: Vec<Line<'static>>,
50+
anchor: Option<ViewportAnchor>,
51+
area: Rect,
52+
buf: &mut Buffer,
53+
) {
54+
if area.width == 0 || area.height == 0 {
55+
return;
56+
}
57+
58+
let footer_paragraph =
59+
Paragraph::new(Text::from(footer_lines.clone())).wrap(Wrap { trim: false });
60+
let footer_rows = footer_paragraph.line_count(area.width);
61+
let footer_height = u16::try_from(footer_rows)
62+
.unwrap_or(u16::MAX)
63+
.min(area.height);
64+
let body_height = area.height.saturating_sub(footer_height);
65+
66+
if body_height > 0 {
67+
render_lines_with_anchor(
68+
body_lines,
69+
anchor,
70+
Rect {
71+
height: body_height,
72+
..area
73+
},
74+
buf,
75+
);
76+
}
77+
78+
let footer_area = Rect {
79+
y: area.y.saturating_add(body_height),
80+
height: footer_height,
81+
..area
82+
};
83+
render_footer_paragraph(footer_paragraph, footer_rows, footer_area, buf);
84+
}
85+
4786
fn scroll_offset_for_anchor(
4887
lines: &[Line<'static>],
4988
anchor: ViewportAnchor,
@@ -84,6 +123,26 @@ fn wrapped_row_count(lines: &[Line<'static>], width: u16) -> usize {
84123
.line_count(width)
85124
}
86125

126+
fn render_footer_paragraph(
127+
paragraph: Paragraph<'_>,
128+
total_rows: usize,
129+
area: Rect,
130+
buf: &mut Buffer,
131+
) {
132+
if area.height == 0 {
133+
return;
134+
}
135+
136+
let visible_rows = area.height as usize;
137+
if total_rows <= visible_rows {
138+
paragraph.render(area, buf);
139+
return;
140+
}
141+
142+
let scroll_offset = total_rows.saturating_sub(visible_rows);
143+
render_scrolled_paragraph(paragraph, total_rows, scroll_offset, area, buf);
144+
}
145+
87146
fn render_scrolled_paragraph(
88147
paragraph: Paragraph<'_>,
89148
total_rows: usize,

crates/tui/src/onboarding_widget.rs

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ use crate::bottom_pane::scroll_state::ScrollState;
4242
use crate::exec_cell::spinner;
4343
use crate::onboarding_viewport::ViewportAnchor;
4444
use crate::onboarding_viewport::render_lines_with_anchor;
45+
use crate::onboarding_viewport::render_lines_with_fixed_footer;
4546
use crate::render::renderable::Renderable;
4647
use crate::tui::frame_requester::FrameRequester;
4748

@@ -796,6 +797,7 @@ impl OnboardingWidget {
796797
}
797798
let current = state.selected_idx.unwrap_or(0);
798799
state.selected_idx = Some(if current == 0 { len - 1 } else { current - 1 });
800+
state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
799801
}
800802

801803
fn model_move_down(state: &mut ScrollState, filtered_indices: &[usize]) {
@@ -805,6 +807,7 @@ impl OnboardingWidget {
805807
}
806808
let current = state.selected_idx.unwrap_or(0);
807809
state.selected_idx = Some((current + 1) % len);
810+
state.ensure_visible(len, MAX_POPUP_ROWS.min(len));
808811
}
809812

810813
fn model_apply_filter(
@@ -832,6 +835,7 @@ impl OnboardingWidget {
832835
} else {
833836
Some(0)
834837
};
838+
state.scroll_top = 0;
835839
}
836840

837841
fn custom_model_name_handle_key(&mut self, key: KeyEvent) {
@@ -2070,15 +2074,9 @@ impl OnboardingWidget {
20702074

20712075
let max_visible = MAX_POPUP_ROWS.min(filtered_indices.len().max(1));
20722076
let scroll_offset = state
2073-
.selected_idx
2074-
.map(|sel| {
2075-
if sel >= max_visible.saturating_sub(2) {
2076-
sel.saturating_sub(max_visible.saturating_sub(3))
2077-
} else {
2078-
0
2079-
}
2080-
})
2081-
.unwrap_or(0);
2077+
.scroll_top
2078+
.min(filtered_indices.len().saturating_sub(max_visible));
2079+
let mut anchor = None;
20822080

20832081
for (vis_idx, &actual_idx) in filtered_indices
20842082
.iter()
@@ -2088,20 +2086,25 @@ impl OnboardingWidget {
20882086
{
20892087
if let Some(item) = items.get(actual_idx) {
20902088
let is_selected = state.selected_idx == Some(vis_idx);
2089+
let start = lines.len();
20912090
let description = if item.display_name == item.slug {
20922091
None
20932092
} else {
20942093
Some(item.display_name.clone())
20952094
};
20962095
Self::render_option_row(&mut lines, item.slug.clone(), description, is_selected);
2096+
if is_selected {
2097+
anchor = Some(ViewportAnchor {
2098+
start,
2099+
end: lines.len(),
2100+
});
2101+
}
20972102
}
20982103
}
20992104

2100-
Self::render_footer(&mut lines, "Enter select", "Esc cancel");
2101-
2102-
Paragraph::new(lines)
2103-
.wrap(Wrap { trim: false })
2104-
.render(content_area, buf);
2105+
let mut footer_lines = Vec::new();
2106+
Self::render_footer(&mut footer_lines, "Enter select", "Esc cancel");
2107+
render_lines_with_fixed_footer(lines, footer_lines, anchor, content_area, buf);
21052108
}
21062109

21072110
fn render_custom_model_name(input: &str, cursor_pos: usize, area: Rect, buf: &mut Buffer) {
@@ -2421,10 +2424,24 @@ impl Renderable for OnboardingWidget {
24212424
fn desired_height(&self, _width: u16) -> u16 {
24222425
match &self.state {
24232426
OnboardingState::ModelSelection {
2424-
filtered_indices, ..
2427+
items,
2428+
state,
2429+
filtered_indices,
2430+
..
24252431
} => {
2426-
let items = MAX_POPUP_ROWS.min(filtered_indices.len().max(1)) as u16;
2427-
items + 8
2432+
let max_visible = MAX_POPUP_ROWS.min(filtered_indices.len().max(1));
2433+
let scroll_offset = state
2434+
.scroll_top
2435+
.min(filtered_indices.len().saturating_sub(max_visible));
2436+
let option_rows = filtered_indices
2437+
.iter()
2438+
.skip(scroll_offset)
2439+
.take(max_visible)
2440+
.filter_map(|idx| items.get(*idx))
2441+
.map(|item| if item.display_name == item.slug { 1 } else { 2 })
2442+
.sum::<u16>()
2443+
.max(1);
2444+
option_rows + 9
24282445
}
24292446
OnboardingState::CustomModelName { .. } => 8,
24302447
OnboardingState::ProviderSelection { items, .. } => items.len() as u16 * 2 + 6,

0 commit comments

Comments
 (0)