1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
//! Full-screen setup shown on a first launch.
//!
//! A first launch has nothing to read and nothing to say, so the session
//! chrome is noise: history is empty, the statusline names a model the user
//! never chose, and the hints describe a composer they cannot use yet. The
//! setup screen replaces all of it with two steps, sign in and choose a model,
//! and hands off to the normal session once they are done.
//!
//! The steps drive the existing pickers rather than a parallel UI. Login and
//! model selection keep their own flows; this module owns only where the user
//! is in the sequence and how the screen is laid out.
use ratatui::{
layout::Rect,
style::Style,
text::{Line, Span},
widgets::{Clear, Paragraph},
Frame,
};
use super::{
first_run::SetupEntry,
render::{display_width, truncate_one_line},
theme::Theme,
App, ComposerMode,
};
/// Widest content column the screen uses. Wider terminals centre this rather
/// than stretching the copy across the full width.
const CONTENT_WIDTH: u16 = 88;
/// Rows of empty space above the welcome block.
const TOP_PADDING: u16 = 2;
/// Where the user is in the first-launch sequence.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum SetupStep {
/// Choosing a provider and finishing its login.
SignIn,
/// Choosing the model the session starts with.
ChooseModel,
}
impl SetupStep {
fn index(self) -> usize {
match self {
Self::SignIn => 0,
Self::ChooseModel => 1,
}
}
}
/// One row of the step list, and how far the user has got.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum StepState {
Done,
Current,
Pending,
}
impl StepState {
fn marker(self) -> &'static str {
match self {
Self::Done => "✓",
Self::Current => "â–¸",
Self::Pending => " ",
}
}
fn style(self) -> Style {
match self {
Self::Done => Theme::success(),
Self::Current => Theme::accent(),
Self::Pending => Theme::dim(),
}
}
}
const STEP_LABELS: [&str; 2] = ["Sign in to a provider", "Choose a model"];
impl App {
pub(super) fn setup_step(&self) -> Option<SetupStep> {
self.setup_screen
}
/// Open the setup screen at the step this launch asked for.
///
/// [`SetupEntry::Auto`] picks the first step that can do anything: models
/// come from the credentials available to the session, stored or from the
/// environment, so a launch with none to offer starts at sign-in, and one
/// that can already list models skips a login that is done. The named
/// entries override that so either step can be opened on demand.
pub(super) fn start_setup_screen(&mut self, terminal: &mut super::DefaultTerminal) {
let Some(entry) = self.info.services.first_run else {
return;
};
let step = match entry {
SetupEntry::SignIn => SetupStep::SignIn,
SetupEntry::ChooseModel => SetupStep::ChooseModel,
SetupEntry::Auto if self.setup_model_picker().is_some() => SetupStep::ChooseModel,
SetupEntry::Auto => SetupStep::SignIn,
};
self.setup_screen = Some(step);
match step {
SetupStep::SignIn => self.open_login_picker(),
SetupStep::ChooseModel => self.open_setup_model_picker(terminal),
}
}
/// Move from sign-in to the model step once a login succeeds.
pub(super) fn advance_setup_screen_after_login(
&mut self,
terminal: &mut super::DefaultTerminal,
) {
match self.setup_screen {
Some(SetupStep::SignIn) => {
self.setup_screen = Some(SetupStep::ChooseModel);
self.open_setup_model_picker(terminal);
}
// A login from the model step or from a normal session changes
// credentials, not which step the user is on.
Some(SetupStep::ChooseModel) | None => {}
}
}
/// Close the screen once a model is live. The session takes over from here.
///
/// The status is left as the caller set it, so a config-save failure during
/// the model switch still reaches the user.
pub(super) fn finish_setup_screen(&mut self) {
match self.setup_screen {
Some(SetupStep::ChooseModel) => self.setup_screen = None,
Some(SetupStep::SignIn) | None => {}
}
}
/// Leave setup when the user backs out of its picker.
///
/// Called from the one place a picker collapses to the plain composer, so
/// Esc always leads somewhere instead of stranding an empty screen. Every
/// step exits the same way, so this needs no per-step handling.
pub(super) fn dismiss_setup_screen(&mut self) {
self.setup_screen = None;
}
/// The model picker for this session, or `None` when the available
/// credentials offer no models to choose between.
fn setup_model_picker(&mut self) -> Option<super::UiPicker> {
self.refresh_available_auths();
let picker = super::model_picker::model_picker(&self.info.runtime, &self.available_auths);
(!picker.items.is_empty()).then_some(picker)
}
/// Open the model picker without the `/model` command's loading redraw,
/// which would paint session chrome over the setup screen.
fn open_setup_model_picker(&mut self, terminal: &mut super::DefaultTerminal) {
let Some(picker) = self.setup_model_picker() else {
// Nothing to choose between: keep the configured model rather than
// showing an empty step.
self.setup_screen = None;
self.set_status("ready");
return;
};
self.input_ui.set_composer(ComposerMode::Picker(picker));
self.set_status("select model");
let _ = terminal.draw(|frame| self.draw(frame));
}
pub(super) fn draw_setup_screen(&mut self, frame: &mut Frame<'_>, area: Rect, step: SetupStep) {
frame.render_widget(Clear, area);
let column = content_column(area);
if column.height == 0 {
return;
}
let width = column.width as usize;
let mut lines = welcome_lines(width);
lines.extend(step_lines(step, width));
lines.push(Line::raw(""));
let body_row = lines.len() as u16;
lines.extend(self.setup_body_lines(width, column.height.saturating_sub(body_row)));
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
truncate_one_line("Esc to skip setup", width),
Theme::dim(),
)));
frame.render_widget(Paragraph::new(lines), column);
if let Some(position) = self.setup_filter_cursor(column, body_row) {
frame.set_cursor_position(position);
}
}
/// The active picker, or a progress line while a login is in flight.
fn setup_body_lines(&mut self, width: usize, height: u16) -> Vec<Line<'static>> {
match self.input_ui.composer() {
// Between two pickers the composer is briefly plain. Report what
// the session is doing instead of the "type a message" prompt.
ComposerMode::Input => vec![Line::from(Span::styled(
truncate_one_line(self.status(), width),
Theme::dim(),
))],
_ => self.composer_lines(width, height as usize),
}
}
/// The picker's filter cursor, placed on the first body row.
fn setup_filter_cursor(
&self,
column: Rect,
body_row: u16,
) -> Option<ratatui::layout::Position> {
let ComposerMode::Picker(picker) = self.input_ui.composer() else {
return None;
};
let offset = display_width(&picker.filter).saturating_add(2);
Some(ratatui::layout::Position {
x: column
.x
.saturating_add(offset.min(column.width.saturating_sub(1) as usize) as u16),
y: column.y.saturating_add(body_row),
})
}
}
/// Centre the content column so wide terminals do not stretch the copy.
fn content_column(area: Rect) -> Rect {
let width = area.width.min(CONTENT_WIDTH);
Rect {
x: area.x.saturating_add(area.width.saturating_sub(width) / 2),
y: area.y.saturating_add(TOP_PADDING),
width,
height: area.height.saturating_sub(TOP_PADDING),
}
}
fn welcome_lines(width: usize) -> Vec<Line<'static>> {
vec![
Line::from(vec![
Span::styled("rho", Theme::brand()),
Span::raw(" v"),
Span::styled(env!("CARGO_PKG_VERSION"), Theme::success()),
]),
Line::raw(""),
Line::from(Span::styled(
truncate_one_line("Welcome. Two steps and you are ready to work.", width),
Theme::text_strong(),
)),
Line::raw(""),
]
}
fn step_lines(step: SetupStep, width: usize) -> Vec<Line<'static>> {
STEP_LABELS
.iter()
.enumerate()
.map(|(index, label)| {
let state = match index.cmp(&step.index()) {
std::cmp::Ordering::Less => StepState::Done,
std::cmp::Ordering::Equal => StepState::Current,
std::cmp::Ordering::Greater => StepState::Pending,
};
Line::from(Span::styled(
truncate_one_line(&format!("{} {label}", state.marker()), width),
state.style(),
))
})
.collect()
}
#[cfg(test)]
#[path = "setup_screen_tests.rs"]
mod tests;