use crate::config::SettingsScope;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoginProviderEntry {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) description: String,
pub(crate) status: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AuthPickerMode {
Login,
Logout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoginPickerState {
pub(crate) entries: Vec<LoginProviderEntry>,
pub(crate) selected: usize,
pub(crate) notice: Option<String>,
pub(crate) mode: AuthPickerMode,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ActiveLoginState {
pub(crate) provider_id: String,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) fallback_input: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LogoutConfirmationState {
pub(crate) provider_id: String,
pub(crate) label: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CustomProviderSetupStep {
Label,
BaseUrl,
ApiKeyEnvVar,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CustomProviderSetupState {
pub(crate) step: CustomProviderSetupStep,
pub(crate) label: String,
pub(crate) provider_id: Option<String>,
pub(crate) base_url: String,
pub(crate) api_key_env_var: String,
pub(crate) error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CustomProviderReplacementConfirmationState {
pub(crate) provider_id: String,
pub(crate) existing_label: String,
pub(crate) label: String,
pub(crate) base_url: String,
pub(crate) api_key_env_var: Option<String>,
}
impl LoginPickerState {
pub(super) fn clamp(&mut self) {
if self.entries.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.entries.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelPickerState {
pub(crate) entries: Vec<crate::model_catalog::ModelCatalogEntry>,
pub(crate) selected: usize,
pub(crate) current_model_id: String,
pub(crate) notice: Option<String>,
}
impl ModelPickerState {
pub(super) fn clamp(&mut self) {
if self.entries.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.entries.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PreviewMessage {
pub(crate) role: &'static str,
pub(crate) text: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionPreview {
pub(crate) first: PreviewMessage,
pub(crate) most_recent: Option<PreviewMessage>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionPreviewPartial {
pub(crate) first: Option<PreviewMessage>,
pub(crate) most_recent: Option<PreviewMessage>,
pub(crate) reason: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum SessionPreviewState {
Ready(SessionPreview),
Empty,
Partial(SessionPreviewPartial),
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SessionPickerFocus {
List,
Preview,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionPickerRow {
pub(crate) id: String,
pub(crate) label: String,
pub(crate) is_current: bool,
pub(crate) preview: Option<SessionPreviewState>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionPickerState {
pub(crate) rows: Vec<SessionPickerRow>,
pub(crate) selected: usize,
pub(crate) focus: SessionPickerFocus,
}
impl SessionPickerState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelToggleRow {
pub(crate) id: String,
pub(crate) provider: String,
pub(crate) display_name: String,
pub(crate) enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillToggleRow {
pub(crate) name: String,
pub(crate) source: String,
pub(crate) enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct McpServerToggleRow {
pub(crate) name: String,
pub(crate) kind: &'static str,
pub(crate) enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolToggleRow {
pub(crate) name: String,
pub(crate) kind: String,
pub(crate) enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentProfileToggleRow {
pub(crate) id: String,
pub(crate) description: String,
pub(crate) enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SkillsModalState {
pub(crate) rows: Vec<SkillToggleRow>,
pub(crate) selected: usize,
pub(crate) settings_scope: SettingsScope,
pub(crate) scope_label: String,
}
impl SkillsModalState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct McpModalState {
pub(crate) rows: Vec<McpServerToggleRow>,
pub(crate) selected: usize,
}
impl McpModalState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolsModalState {
pub(crate) rows: Vec<ToolToggleRow>,
pub(crate) selected: usize,
pub(crate) settings_scope: SettingsScope,
pub(crate) scope_label: String,
}
impl ToolsModalState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentsModalState {
pub(crate) rows: Vec<SubagentProfileToggleRow>,
pub(crate) selected: usize,
pub(crate) settings_scope: SettingsScope,
pub(crate) scope_label: String,
}
impl SubagentsModalState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ModelsModalState {
pub(crate) rows: Vec<ModelToggleRow>,
pub(crate) selected: usize,
pub(crate) settings_scope: SettingsScope,
pub(crate) scope_label: String,
pub(crate) loading: bool,
}
impl ModelsModalState {
pub(super) fn clamp(&mut self) {
if self.rows.is_empty() {
self.selected = 0;
} else {
self.selected = self.selected.min(self.rows.len() - 1);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SystemPromptModalState {
pub(crate) content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct UsageModalState {
pub(crate) loading: bool,
pub(crate) result: Option<crate::tui::usage::UsageLoadResult>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RewindModalState {
pub(crate) content: String,
pub(crate) plan: Option<crate::checkpoints::RestorePlan>,
pub(crate) plans: Vec<crate::checkpoints::RestorePlan>,
pub(crate) selected: usize,
pub(crate) dry_run: bool,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ModalState {
pub(crate) model_picker: Option<ModelPickerState>,
pub(crate) login_picker: Option<LoginPickerState>,
pub(crate) active_login: Option<ActiveLoginState>,
pub(crate) custom_provider_setup: Option<CustomProviderSetupState>,
pub(crate) logout_confirmation: Option<LogoutConfirmationState>,
pub(crate) custom_provider_replacement_confirmation:
Option<CustomProviderReplacementConfirmationState>,
pub(crate) system_prompt_modal: Option<SystemPromptModalState>,
pub(crate) usage_modal: Option<UsageModalState>,
pub(crate) rewind_modal: Option<RewindModalState>,
pub(crate) skills_modal: Option<SkillsModalState>,
pub(crate) mcp_modal: Option<McpModalState>,
pub(crate) tools_modal: Option<ToolsModalState>,
pub(crate) subagents_modal: Option<SubagentsModalState>,
pub(crate) models_modal: Option<ModelsModalState>,
pub(crate) session_picker: Option<SessionPickerState>,
}
use super::MissionControlState;
use crate::tui::transcript;
fn format_session_preview(preview: Option<&SessionPreviewState>) -> String {
match preview {
Some(SessionPreviewState::Ready(preview)) => format_ready_session_preview(preview),
Some(SessionPreviewState::Empty) => "No previewable conversational messages".to_string(),
Some(SessionPreviewState::Partial(partial)) => {
let mut text = format!("Preview limited: {}", partial.reason);
if let Some(first) = &partial.first {
text.push_str("\n\nFirst message\n");
text.push_str(&format_preview_message(first));
}
if let Some(recent) = &partial.most_recent {
text.push_str("\n\nMost recent message\n");
text.push_str(&format_preview_message(recent));
}
text
}
Some(SessionPreviewState::Error(error)) => format!("Could not read preview: {error}"),
None => "Preview not loaded yet".to_string(),
}
}
fn format_ready_session_preview(preview: &SessionPreview) -> String {
match &preview.most_recent {
Some(recent) => format!(
"First message\n{}\n\nMost recent message\n{}",
format_preview_message(&preview.first),
format_preview_message(recent)
),
None => format!(
"First / most recent message\n{}",
format_preview_message(&preview.first)
),
}
}
fn format_preview_message(message: &PreviewMessage) -> String {
format!("{}: {}", message.role, message.text)
}
impl MissionControlState {
pub(crate) fn open_session_picker(
&mut self,
rows: Vec<SessionPickerRow>,
_visible_rows: usize,
) {
let mut picker = SessionPickerState {
rows,
selected: 0,
focus: SessionPickerFocus::List,
};
picker.clamp();
self.modals.session_picker = Some(picker);
self.set_scroll_offset(&self.scroll_views.session_list, 0);
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
self.hide_autocomplete();
}
pub(crate) fn close_session_picker(&mut self) -> bool {
self.modals.session_picker.take().is_some()
}
pub(crate) fn session_picker_visible(&self) -> bool {
self.modals.session_picker.is_some()
}
pub(crate) fn session_picker_up(&mut self, visible_rows: usize) -> bool {
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_sub(1);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.session_list);
self.clamp_session_picker_scroll(visible_rows);
let changed = before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.session_list);
if changed && before_selected != selected {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
changed
}
pub(crate) fn session_picker_down(&mut self, visible_rows: usize) -> bool {
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
if !picker.rows.is_empty() {
picker.selected = picker.selected.saturating_add(1).min(picker.rows.len() - 1);
}
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.session_list);
self.clamp_session_picker_scroll(visible_rows);
let changed = before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.session_list);
if changed && before_selected != selected {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
changed
}
pub(crate) fn session_picker_page_up(&mut self, visible_rows: usize) -> bool {
let rows = visible_rows.max(1);
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_sub(rows);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.session_list);
self.clamp_session_picker_scroll(rows);
let changed = before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.session_list);
if changed && before_selected != selected {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
changed
}
pub(crate) fn session_picker_page_down(&mut self, visible_rows: usize) -> bool {
let rows = visible_rows.max(1);
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
if !picker.rows.is_empty() {
picker.selected = picker
.selected
.saturating_add(rows)
.min(picker.rows.len() - 1);
}
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.session_list);
self.clamp_session_picker_scroll(rows);
let changed = before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.session_list);
if changed && before_selected != selected {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
changed
}
pub(crate) fn session_picker_focus_next(&mut self) -> bool {
let Some(picker) = &mut self.modals.session_picker else {
return false;
};
picker.focus = match picker.focus {
SessionPickerFocus::List => SessionPickerFocus::Preview,
SessionPickerFocus::Preview => SessionPickerFocus::List,
};
true
}
pub(crate) fn session_picker_focus_previous(&mut self) -> bool {
self.session_picker_focus_next()
}
pub(crate) fn session_picker_preview_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.session_preview);
let overflow = self.session_picker_preview_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.session_preview,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.session_preview) != before
}
pub(crate) fn session_picker_preview_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.session_preview);
let overflow = self.session_picker_preview_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.session_preview,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.session_preview) != before
}
pub(crate) fn session_picker_preview_scroll_overflow(
&self,
visible_rows: u16,
wrap_width: u16,
) -> usize {
transcript::ratatui_wrapped_visual_rows(
&self.session_picker_preview_text(),
wrap_width.max(1),
)
.saturating_sub(visible_rows.max(1) as usize)
}
pub(crate) fn session_picker_preview_text(&self) -> String {
let Some(picker) = &self.modals.session_picker else {
return String::new();
};
let Some(row) = picker.rows.get(picker.selected) else {
return "No previewable conversational messages".to_string();
};
format_session_preview(row.preview.as_ref())
}
pub(crate) fn set_session_preview(
&mut self,
session_id: &str,
preview: SessionPreviewState,
) -> bool {
let Some(picker) = &mut self.modals.session_picker else {
return false;
};
let Some(row) = picker.rows.iter_mut().find(|row| row.id == session_id) else {
return false;
};
let changed = row.preview.as_ref() != Some(&preview);
row.preview = Some(preview);
if changed {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
changed
}
pub(crate) fn selected_session_preview_needs_load(&self) -> Option<String> {
self.modals
.session_picker
.as_ref()
.and_then(|picker| picker.rows.get(picker.selected))
.filter(|row| row.preview.is_none())
.map(|row| row.id.clone())
}
fn clamp_session_picker_scroll(&self, visible_rows: usize) {
let Some(picker) = &self.modals.session_picker else {
return;
};
let visible_rows = visible_rows.max(1);
let max_offset = picker.rows.len().saturating_sub(visible_rows);
let mut offset = self
.scroll_offset(&self.scroll_views.session_list)
.min(max_offset);
if picker.selected < offset {
offset = picker.selected;
} else if picker.selected >= offset.saturating_add(visible_rows) {
offset = picker.selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.session_list, offset.min(max_offset));
}
pub(crate) fn session_picker_select_visible_row(
&mut self,
visible_index: usize,
_visible_rows: usize,
) -> bool {
let target = self
.scroll_offset(&self.scroll_views.session_list)
.saturating_add(visible_index);
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
if target >= picker.rows.len() {
return false;
}
let before_selected = picker.selected;
picker.selected = target;
picker.clamp();
let selected = picker.selected;
if before_selected != selected {
self.set_scroll_offset(&self.scroll_views.session_preview, 0);
}
before_selected != selected
}
pub(crate) fn selected_session_id(&self) -> Option<String> {
self.modals
.session_picker
.as_ref()
.and_then(|picker| picker.rows.get(picker.selected))
.map(|row| row.id.clone())
}
#[cfg(test)]
pub(crate) fn open_skills_modal(&mut self, rows: Vec<SkillToggleRow>, visible: usize) {
self.open_skills_modal_scoped(rows, visible, SettingsScope::Global, "Global".to_string());
}
pub(crate) fn open_skills_modal_scoped(
&mut self,
rows: Vec<SkillToggleRow>,
_visible: usize,
scope: SettingsScope,
label: String,
) {
let mut m = SkillsModalState {
rows,
selected: 0,
settings_scope: scope,
scope_label: label,
};
m.clamp();
self.modals.skills_modal = Some(m);
self.set_scroll_offset(&self.scroll_views.skills_modal, 0);
self.hide_autocomplete();
}
#[cfg(test)]
pub(crate) fn open_tools_modal(&mut self, rows: Vec<ToolToggleRow>, visible: usize) {
self.open_tools_modal_scoped(rows, visible, SettingsScope::Global, "Global".to_string());
}
pub(crate) fn open_tools_modal_scoped(
&mut self,
rows: Vec<ToolToggleRow>,
_visible: usize,
scope: SettingsScope,
label: String,
) {
let mut m = ToolsModalState {
rows,
selected: 0,
settings_scope: scope,
scope_label: label,
};
m.clamp();
self.modals.tools_modal = Some(m);
self.set_scroll_offset(&self.scroll_views.tools_modal, 0);
self.hide_autocomplete();
}
#[cfg(test)]
pub(crate) fn open_subagents_modal(
&mut self,
rows: Vec<SubagentProfileToggleRow>,
visible: usize,
) {
self.open_subagents_modal_scoped(
rows,
visible,
SettingsScope::Global,
"Global".to_string(),
);
}
pub(crate) fn open_subagents_modal_scoped(
&mut self,
rows: Vec<SubagentProfileToggleRow>,
_visible: usize,
scope: SettingsScope,
label: String,
) {
let mut m = SubagentsModalState {
rows,
selected: 0,
settings_scope: scope,
scope_label: label,
};
m.clamp();
self.modals.subagents_modal = Some(m);
self.set_scroll_offset(&self.scroll_views.subagents_modal, 0);
self.hide_autocomplete();
}
pub(crate) fn skills_modal_scope(&self) -> Option<SettingsScope> {
self.modals
.skills_modal
.as_ref()
.map(|modal| modal.settings_scope)
}
pub(crate) fn set_skills_modal_scope_rows(
&mut self,
scope: SettingsScope,
label: String,
rows: Vec<SkillToggleRow>,
_visible: usize,
) {
if let Some(m) = &mut self.modals.skills_modal {
m.settings_scope = scope;
m.scope_label = label;
m.rows = rows;
m.clamp();
}
self.set_scroll_offset(&self.scroll_views.skills_modal, 0);
}
pub(crate) fn close_skills_modal(&mut self) -> bool {
self.modals.skills_modal.take().is_some()
}
pub(crate) fn skills_modal_visible(&self) -> bool {
self.modals.skills_modal.is_some()
}
pub(crate) fn skills_modal_up(&mut self, visible_rows: usize) -> bool {
self.move_skill_selection(false, visible_rows)
}
pub(crate) fn skills_modal_down(&mut self, visible_rows: usize) -> bool {
self.move_skill_selection(true, visible_rows)
}
fn move_skill_selection(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.skills_modal);
let Some(m) = &mut self.modals.skills_modal else {
return false;
};
let before = m.selected;
if down && !m.rows.is_empty() {
m.selected = m.selected.saturating_add(1).min(m.rows.len() - 1);
} else if !down {
m.selected = m.selected.saturating_sub(1);
}
m.clamp();
let selected = m.selected;
let visible_rows = visible_rows.max(1);
let max_offset = m.rows.len().saturating_sub(visible_rows);
let mut offset = before_offset.min(max_offset);
if selected < offset {
offset = selected;
} else if selected >= offset + visible_rows {
offset = selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.skills_modal, offset.min(max_offset));
before != selected || before_offset != self.scroll_offset(&self.scroll_views.skills_modal)
}
pub(crate) fn toggle_selected_skill(&mut self) -> Option<(String, bool)> {
let m = self.modals.skills_modal.as_mut()?;
let r = m.rows.get_mut(m.selected)?;
r.enabled = !r.enabled;
Some((r.name.clone(), r.enabled))
}
pub(crate) fn set_skill_row_enabled(&mut self, name: &str, enabled: bool) {
if let Some(m) = &mut self.modals.skills_modal
&& let Some(r) = m.rows.iter_mut().find(|r| r.name == name)
{
r.enabled = enabled;
}
}
pub(crate) fn open_mcp_modal(&mut self, rows: Vec<McpServerToggleRow>, _visible: usize) {
let mut m = McpModalState { rows, selected: 0 };
m.clamp();
self.modals.mcp_modal = Some(m);
self.set_scroll_offset(&self.scroll_views.mcp_modal, 0);
self.hide_autocomplete();
}
pub(crate) fn close_mcp_modal(&mut self) -> bool {
self.modals.mcp_modal.take().is_some()
}
pub(crate) fn mcp_modal_visible(&self) -> bool {
self.modals.mcp_modal.is_some()
}
pub(crate) fn mcp_modal_up(&mut self, visible_rows: usize) -> bool {
self.move_mcp_selection(false, visible_rows)
}
pub(crate) fn mcp_modal_down(&mut self, visible_rows: usize) -> bool {
self.move_mcp_selection(true, visible_rows)
}
fn move_mcp_selection(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.mcp_modal);
let Some(m) = &mut self.modals.mcp_modal else {
return false;
};
let before = m.selected;
if down && !m.rows.is_empty() {
m.selected = m.selected.saturating_add(1).min(m.rows.len() - 1);
} else if !down {
m.selected = m.selected.saturating_sub(1);
}
m.clamp();
let selected = m.selected;
let visible_rows = visible_rows.max(1);
let max_offset = m.rows.len().saturating_sub(visible_rows);
let mut offset = before_offset.min(max_offset);
if selected < offset {
offset = selected;
} else if selected >= offset + visible_rows {
offset = selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.mcp_modal, offset.min(max_offset));
before != selected || before_offset != self.scroll_offset(&self.scroll_views.mcp_modal)
}
pub(crate) fn toggle_selected_mcp_server(&mut self) -> Option<(String, bool)> {
let m = self.modals.mcp_modal.as_mut()?;
let r = m.rows.get_mut(m.selected)?;
r.enabled = !r.enabled;
Some((r.name.clone(), r.enabled))
}
pub(crate) fn set_mcp_server_row_enabled(&mut self, name: &str, enabled: bool) {
if let Some(m) = &mut self.modals.mcp_modal
&& let Some(r) = m.rows.iter_mut().find(|r| r.name == name)
{
r.enabled = enabled;
}
}
pub(crate) fn tools_modal_scope(&self) -> Option<SettingsScope> {
self.modals.tools_modal.as_ref().map(|m| m.settings_scope)
}
pub(crate) fn set_tools_modal_scope_rows(
&mut self,
scope: SettingsScope,
label: String,
rows: Vec<ToolToggleRow>,
_visible: usize,
) {
if let Some(m) = &mut self.modals.tools_modal {
m.settings_scope = scope;
m.scope_label = label;
m.rows = rows;
m.clamp();
}
self.set_scroll_offset(&self.scroll_views.tools_modal, 0);
}
pub(crate) fn close_tools_modal(&mut self) -> bool {
self.modals.tools_modal.take().is_some()
}
pub(crate) fn tools_modal_visible(&self) -> bool {
self.modals.tools_modal.is_some()
}
pub(crate) fn tools_modal_up(&mut self, visible_rows: usize) -> bool {
self.move_tools_selection(false, visible_rows)
}
pub(crate) fn tools_modal_down(&mut self, visible_rows: usize) -> bool {
self.move_tools_selection(true, visible_rows)
}
fn move_tools_selection(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.tools_modal);
let Some(m) = &mut self.modals.tools_modal else {
return false;
};
let before = m.selected;
if down && !m.rows.is_empty() {
m.selected = m.selected.saturating_add(1).min(m.rows.len() - 1);
} else if !down {
m.selected = m.selected.saturating_sub(1);
}
m.clamp();
let selected = m.selected;
let visible_rows = visible_rows.max(1);
let max_offset = m.rows.len().saturating_sub(visible_rows);
let mut offset = before_offset.min(max_offset);
if selected < offset {
offset = selected;
} else if selected >= offset + visible_rows {
offset = selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.tools_modal, offset.min(max_offset));
before != selected || before_offset != self.scroll_offset(&self.scroll_views.tools_modal)
}
pub(crate) fn toggle_selected_tool(&mut self) -> Option<(String, bool)> {
let m = self.modals.tools_modal.as_mut()?;
let r = m.rows.get_mut(m.selected)?;
r.enabled = !r.enabled;
Some((r.name.clone(), r.enabled))
}
pub(crate) fn set_tool_row_enabled(&mut self, name: &str, enabled: bool) {
if let Some(m) = &mut self.modals.tools_modal
&& let Some(r) = m.rows.iter_mut().find(|r| r.name == name)
{
r.enabled = enabled;
}
}
pub(crate) fn subagents_modal_scope(&self) -> Option<SettingsScope> {
self.modals
.subagents_modal
.as_ref()
.map(|m| m.settings_scope)
}
pub(crate) fn set_subagents_modal_scope_rows(
&mut self,
scope: SettingsScope,
label: String,
rows: Vec<SubagentProfileToggleRow>,
_visible: usize,
) {
if let Some(m) = &mut self.modals.subagents_modal {
m.settings_scope = scope;
m.scope_label = label;
m.rows = rows;
m.clamp();
}
self.set_scroll_offset(&self.scroll_views.subagents_modal, 0);
}
pub(crate) fn close_subagents_modal(&mut self) -> bool {
self.modals.subagents_modal.take().is_some()
}
pub(crate) fn subagents_modal_visible(&self) -> bool {
self.modals.subagents_modal.is_some()
}
pub(crate) fn subagents_modal_up(&mut self, visible_rows: usize) -> bool {
self.move_subagents_selection(false, visible_rows)
}
pub(crate) fn subagents_modal_down(&mut self, visible_rows: usize) -> bool {
self.move_subagents_selection(true, visible_rows)
}
fn move_subagents_selection(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.subagents_modal);
let Some(m) = &mut self.modals.subagents_modal else {
return false;
};
let before = m.selected;
if down && !m.rows.is_empty() {
m.selected = m.selected.saturating_add(1).min(m.rows.len() - 1);
} else if !down {
m.selected = m.selected.saturating_sub(1);
}
m.clamp();
let selected = m.selected;
let visible_rows = visible_rows.max(1);
let max_offset = m.rows.len().saturating_sub(visible_rows);
let mut offset = before_offset.min(max_offset);
if selected < offset {
offset = selected;
} else if selected >= offset + visible_rows {
offset = selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.subagents_modal, offset.min(max_offset));
before != selected
|| before_offset != self.scroll_offset(&self.scroll_views.subagents_modal)
}
pub(crate) fn toggle_selected_subagent_profile(&mut self) -> Option<(String, bool)> {
let m = self.modals.subagents_modal.as_mut()?;
let r = m.rows.get_mut(m.selected)?;
r.enabled = !r.enabled;
Some((r.id.clone(), r.enabled))
}
pub(crate) fn set_subagent_profile_row_enabled(&mut self, id: &str, enabled: bool) {
if let Some(m) = &mut self.modals.subagents_modal
&& let Some(r) = m.rows.iter_mut().find(|r| r.id == id)
{
r.enabled = enabled;
}
}
pub(crate) fn models_modal_scope(&self) -> Option<SettingsScope> {
self.modals
.models_modal
.as_ref()
.map(|modal| modal.settings_scope)
}
pub(crate) fn set_models_modal_scope_rows(
&mut self,
settings_scope: SettingsScope,
scope_label: String,
rows: Vec<ModelToggleRow>,
_visible_rows: usize,
) {
if let Some(modal) = &mut self.modals.models_modal {
modal.settings_scope = settings_scope;
modal.scope_label = scope_label;
modal.rows = rows;
modal.loading = false;
modal.clamp();
}
self.clamp_models_modal_scroll(_visible_rows);
}
pub(crate) fn set_models_rows(
&mut self,
rows: Vec<ModelToggleRow>,
loading: bool,
visible_rows: usize,
) {
if let Some(modal) = &mut self.modals.models_modal {
let selected_id = modal.rows.get(modal.selected).map(|row| row.id.clone());
modal.rows = rows;
if let Some(selected_id) = selected_id
&& let Some(index) = modal.rows.iter().position(|row| row.id == selected_id)
{
modal.selected = index;
}
modal.loading = loading;
modal.clamp();
}
self.clamp_models_modal_scroll(visible_rows);
}
pub(crate) fn close_models_modal(&mut self) -> bool {
self.modals.models_modal.take().is_some()
}
pub(crate) fn models_modal_visible(&self) -> bool {
self.modals.models_modal.is_some()
}
pub(crate) fn models_modal_up(&mut self, visible_rows: usize) -> bool {
let Some(modal) = &mut self.modals.models_modal else {
return false;
};
let before_selected = modal.selected;
modal.selected = modal.selected.saturating_sub(1);
modal.clamp();
let selected = modal.selected;
let before_offset = self.scroll_offset(&self.scroll_views.models_modal);
self.clamp_models_modal_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.models_modal)
}
pub(crate) fn models_modal_down(&mut self, visible_rows: usize) -> bool {
let Some(modal) = &mut self.modals.models_modal else {
return false;
};
let before_selected = modal.selected;
modal.selected = modal.selected.saturating_add(1);
modal.clamp();
let selected = modal.selected;
let before_offset = self.scroll_offset(&self.scroll_views.models_modal);
self.clamp_models_modal_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.models_modal)
}
fn clamp_models_modal_scroll(&self, visible_rows: usize) {
let Some(modal) = &self.modals.models_modal else {
return;
};
let visible_rows = visible_rows.max(1);
let max_offset = modal.rows.len().saturating_sub(visible_rows);
let mut offset = self
.scroll_offset(&self.scroll_views.models_modal)
.min(max_offset);
if modal.selected < offset {
offset = modal.selected;
} else if modal.selected >= offset.saturating_add(visible_rows) {
offset = modal.selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.models_modal, offset.min(max_offset));
}
pub(crate) fn toggle_selected_model_enabled(&mut self) -> Option<(String, bool)> {
let modal = self.modals.models_modal.as_mut()?;
if modal.loading {
return None;
}
let row = modal.rows.get_mut(modal.selected)?;
row.enabled = !row.enabled;
Some((row.id.clone(), row.enabled))
}
pub(crate) fn set_model_row_enabled(&mut self, id: &str, enabled: bool) {
if let Some(modal) = &mut self.modals.models_modal
&& let Some(row) = modal.rows.iter_mut().find(|row| row.id == id)
{
row.enabled = enabled;
}
}
pub(crate) fn open_usage_modal_loading(&mut self) {
self.modals.usage_modal = Some(UsageModalState {
loading: true,
result: None,
});
self.hide_autocomplete();
}
pub(crate) fn set_usage_modal_result(
&mut self,
result: crate::tui::usage::UsageLoadResult,
) -> bool {
let Some(modal) = &mut self.modals.usage_modal else {
return false;
};
let changed = modal.loading || modal.result.as_ref() != Some(&result);
modal.loading = false;
modal.result = Some(result);
self.set_scroll_offset(&self.scroll_views.usage_modal, 0);
changed
}
pub(crate) fn close_usage_modal(&mut self) -> bool {
self.pending_usage_request_id = None;
self.modals.usage_modal.take().is_some()
}
pub(crate) fn usage_modal_visible(&self) -> bool {
self.modals.usage_modal.is_some()
}
pub(crate) fn scroll_usage_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.usage_modal);
let overflow = self.usage_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.usage_modal,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.usage_modal) != before
}
pub(crate) fn scroll_usage_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.usage_modal);
let overflow = self.usage_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.usage_modal,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.usage_modal) != before
}
pub(crate) fn usage_scroll_overflow(&self, visible_rows: u16, wrap_width: u16) -> usize {
let Some(modal) = &self.modals.usage_modal else {
return 0;
};
crate::tui::usage::usage_visual_rows(modal.loading, modal.result.as_ref(), wrap_width)
.saturating_sub(visible_rows.max(1) as usize)
}
pub(crate) fn open_system_prompt_modal(&mut self, content: String) {
self.modals.system_prompt_modal = Some(SystemPromptModalState { content });
self.set_scroll_offset(&self.scroll_views.system_prompt, 0);
self.hide_autocomplete();
}
pub(crate) fn close_system_prompt_modal(&mut self) -> bool {
self.modals.system_prompt_modal.take().is_some()
}
pub(crate) fn system_prompt_modal_visible(&self) -> bool {
self.modals.system_prompt_modal.is_some()
}
pub(crate) fn scroll_system_prompt_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.system_prompt);
let overflow = self.system_prompt_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.system_prompt,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.system_prompt) != before
}
pub(crate) fn scroll_system_prompt_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.system_prompt);
let overflow = self.system_prompt_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.system_prompt,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.system_prompt) != before
}
pub(crate) fn system_prompt_scroll_overflow(
&self,
visible_rows: u16,
wrap_width: u16,
) -> usize {
let Some(modal) = &self.modals.system_prompt_modal else {
return 0;
};
transcript::ratatui_wrapped_visual_rows(&modal.content, wrap_width.max(1))
.saturating_sub(visible_rows.max(1) as usize)
}
pub(crate) fn open_rewind_modal(
&mut self,
content: String,
plan: Option<crate::checkpoints::RestorePlan>,
dry_run: bool,
) {
self.modals.rewind_modal = Some(RewindModalState {
content,
plan,
plans: Vec::new(),
selected: 0,
dry_run,
});
self.set_scroll_offset(&self.scroll_views.rewind_modal, 0);
self.hide_autocomplete();
}
pub(crate) fn open_rewind_picker(&mut self, plans: Vec<crate::checkpoints::RestorePlan>) {
let content = plans
.first()
.map(crate::shell::format_rewind_plan_for_tui)
.unwrap_or_else(|| "no rewindable file changes".to_string());
self.modals.rewind_modal = Some(RewindModalState {
content,
plan: None,
plans,
selected: 0,
dry_run: false,
});
self.set_scroll_offset(&self.scroll_views.rewind_modal, 0);
self.hide_autocomplete();
}
pub(crate) fn rewind_picker_up(&mut self) -> bool {
let changed = {
let Some(modal) = &mut self.modals.rewind_modal else {
return false;
};
if modal.plans.is_empty() {
return false;
}
let before = modal.selected;
modal.selected = modal.selected.saturating_sub(1);
modal.content = crate::shell::format_rewind_plan_for_tui(&modal.plans[modal.selected]);
before != modal.selected
};
self.set_scroll_offset(&self.scroll_views.rewind_modal, 0);
changed
}
pub(crate) fn rewind_picker_down(&mut self) -> bool {
let changed = {
let Some(modal) = &mut self.modals.rewind_modal else {
return false;
};
if modal.plans.is_empty() {
return false;
}
let before = modal.selected;
modal.selected = modal.selected.saturating_add(1).min(modal.plans.len() - 1);
modal.content = crate::shell::format_rewind_plan_for_tui(&modal.plans[modal.selected]);
before != modal.selected
};
self.set_scroll_offset(&self.scroll_views.rewind_modal, 0);
changed
}
pub(crate) fn close_rewind_modal(&mut self) -> bool {
self.modals.rewind_modal.take().is_some()
}
pub(crate) fn rewind_modal_visible(&self) -> bool {
self.modals.rewind_modal.is_some()
}
pub(crate) fn take_rewind_plan(&mut self) -> Option<crate::checkpoints::RestorePlan> {
self.modals.rewind_modal.take().and_then(|modal| {
modal
.plan
.or_else(|| modal.plans.get(modal.selected).cloned())
})
}
pub(crate) fn scroll_rewind_up_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.rewind_modal);
let overflow = self.rewind_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.rewind_modal,
before.saturating_sub(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.rewind_modal) != before
}
pub(crate) fn scroll_rewind_down_by(
&mut self,
rows: u16,
visible_rows: u16,
wrap_width: u16,
) -> bool {
let before = self.scroll_offset(&self.scroll_views.rewind_modal);
let overflow = self.rewind_scroll_overflow(visible_rows, wrap_width);
self.set_scroll_offset(
&self.scroll_views.rewind_modal,
before.saturating_add(usize::from(rows)).min(overflow),
);
self.scroll_offset(&self.scroll_views.rewind_modal) != before
}
pub(crate) fn rewind_scroll_overflow(&self, visible_rows: u16, wrap_width: u16) -> usize {
let Some(modal) = &self.modals.rewind_modal else {
return 0;
};
transcript::ratatui_wrapped_visual_rows(&modal.content, wrap_width.max(1))
.saturating_sub(visible_rows.max(1) as usize)
}
pub(crate) fn open_logout_confirmation(&mut self, provider_id: String, label: String) {
self.modals.logout_confirmation = Some(LogoutConfirmationState { provider_id, label });
self.hide_autocomplete();
}
pub(crate) fn close_logout_confirmation(&mut self) -> bool {
self.modals.logout_confirmation.take().is_some()
}
pub(crate) fn open_custom_provider_replacement_confirmation(
&mut self,
provider_id: String,
existing_label: String,
label: String,
base_url: String,
api_key_env_var: Option<String>,
) {
self.modals.custom_provider_replacement_confirmation =
Some(CustomProviderReplacementConfirmationState {
provider_id,
existing_label,
label,
base_url,
api_key_env_var,
});
self.hide_autocomplete();
}
pub(crate) fn close_custom_provider_replacement_confirmation(&mut self) -> bool {
self.modals
.custom_provider_replacement_confirmation
.take()
.is_some()
}
pub(crate) fn start_custom_provider_setup(&mut self) {
self.modals.custom_provider_setup = Some(CustomProviderSetupState {
step: CustomProviderSetupStep::Label,
label: String::new(),
provider_id: None,
base_url: String::new(),
api_key_env_var: String::new(),
error: None,
});
self.hide_autocomplete();
}
pub(crate) fn clear_custom_provider_setup(&mut self) {
self.modals.custom_provider_setup = None;
}
pub(crate) fn custom_provider_setup_input_mut(&mut self) -> Option<&mut String> {
let setup = self.modals.custom_provider_setup.as_mut()?;
Some(match setup.step {
CustomProviderSetupStep::Label => &mut setup.label,
CustomProviderSetupStep::BaseUrl => &mut setup.base_url,
CustomProviderSetupStep::ApiKeyEnvVar => &mut setup.api_key_env_var,
})
}
pub(crate) fn insert_custom_provider_setup_char(&mut self, ch: char) {
if let Some(input) = self.custom_provider_setup_input_mut() {
input.push(ch);
}
if let Some(setup) = &mut self.modals.custom_provider_setup {
setup.error = None;
if setup.step == CustomProviderSetupStep::Label {
setup.provider_id = crate::config::derive_custom_provider_id(&setup.label).ok();
}
}
}
pub(crate) fn backspace_custom_provider_setup(&mut self) {
if let Some(input) = self.custom_provider_setup_input_mut() {
input.pop();
}
if let Some(setup) = &mut self.modals.custom_provider_setup {
setup.error = None;
if setup.step == CustomProviderSetupStep::Label {
setup.provider_id = crate::config::derive_custom_provider_id(&setup.label).ok();
}
}
}
pub(crate) fn set_custom_provider_setup_error(&mut self, error: impl Into<String>) {
if let Some(setup) = &mut self.modals.custom_provider_setup {
setup.error = Some(error.into());
}
}
pub(crate) fn start_active_login(&mut self, provider_id: String) {
self.modals.active_login = Some(ActiveLoginState {
provider_id,
instructions: "Starting login…".to_string(),
url: String::new(),
fallback_input: String::new(),
});
self.hide_autocomplete();
}
pub(crate) fn set_login_instructions(&mut self, url: String, instructions: String) {
if let Some(login) = &mut self.modals.active_login {
login.url = url;
login.instructions = instructions;
login.fallback_input.clear();
}
}
pub(crate) fn clear_active_login(&mut self) {
if let Some(login) = &mut self.modals.active_login {
login.fallback_input.clear();
}
self.modals.active_login = None;
}
pub(crate) fn insert_login_fallback_char(&mut self, ch: char) {
if let Some(login) = &mut self.modals.active_login {
login.fallback_input.push(ch);
}
}
pub(crate) fn backspace_login_fallback(&mut self) {
if let Some(login) = &mut self.modals.active_login {
login.fallback_input.pop();
}
}
pub(crate) fn take_login_fallback_input(&mut self) -> Option<String> {
let login = self.modals.active_login.as_mut()?;
if login.fallback_input.trim().is_empty() {
return None;
}
Some(std::mem::take(&mut login.fallback_input))
}
pub(crate) fn open_login_picker(
&mut self,
entries: Vec<LoginProviderEntry>,
notice: Option<String>,
visible_rows: usize,
) {
self.open_auth_picker(entries, notice, visible_rows, AuthPickerMode::Login);
}
pub(crate) fn open_logout_picker(
&mut self,
entries: Vec<LoginProviderEntry>,
notice: Option<String>,
visible_rows: usize,
) {
self.open_auth_picker(entries, notice, visible_rows, AuthPickerMode::Logout);
}
fn open_auth_picker(
&mut self,
entries: Vec<LoginProviderEntry>,
notice: Option<String>,
_visible_rows: usize,
mode: AuthPickerMode,
) {
let mut picker = LoginPickerState {
entries,
selected: 0,
notice,
mode,
};
picker.clamp();
self.modals.login_picker = Some(picker);
self.set_scroll_offset(&self.scroll_views.login_picker, 0);
self.hide_autocomplete();
}
pub(crate) fn close_login_picker(&mut self) -> bool {
self.modals.login_picker.take().is_some()
}
pub(crate) fn login_picker_up(&mut self, visible_rows: usize) -> bool {
let Some(picker) = &mut self.modals.login_picker else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_sub(1);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.login_picker);
self.clamp_login_picker_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.login_picker)
}
pub(crate) fn login_picker_down(&mut self, visible_rows: usize) -> bool {
let Some(picker) = &mut self.modals.login_picker else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_add(1);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.login_picker);
self.clamp_login_picker_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.login_picker)
}
fn clamp_login_picker_scroll(&self, visible_rows: usize) {
let Some(picker) = &self.modals.login_picker else {
return;
};
let visible_rows = visible_rows.max(1);
let max_offset = picker.entries.len().saturating_sub(visible_rows);
let mut offset = self
.scroll_offset(&self.scroll_views.login_picker)
.min(max_offset);
if picker.selected < offset {
offset = picker.selected;
} else if picker.selected >= offset.saturating_add(visible_rows) {
offset = picker.selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.login_picker, offset.min(max_offset));
}
pub(crate) fn selected_login_provider_id(&self) -> Option<String> {
self.modals
.login_picker
.as_ref()
.and_then(|picker| picker.entries.get(picker.selected))
.map(|entry| entry.id.clone())
}
pub(crate) fn open_model_picker(
&mut self,
entries: Vec<crate::model_catalog::ModelCatalogEntry>,
disabled_ids: std::collections::BTreeSet<String>,
notice: Option<String>,
_visible_rows: usize,
) {
let current_model_id = format!("{}/{}", self.provider, self.model);
let entries = entries
.into_iter()
.filter(|entry| !disabled_ids.contains(&entry.id))
.collect::<Vec<_>>();
let selected = entries
.iter()
.position(|entry| entry.id == current_model_id)
.unwrap_or(0);
let mut picker = ModelPickerState {
entries,
selected,
current_model_id,
notice,
};
picker.clamp();
self.modals.model_picker = Some(picker);
self.set_scroll_offset(&self.scroll_views.model_picker, 0);
self.hide_autocomplete();
}
pub(crate) fn close_model_picker(&mut self) -> bool {
self.modals.model_picker.take().is_some()
}
pub(crate) fn model_picker_up(&mut self, visible_rows: usize) -> bool {
let Some(picker) = &mut self.modals.model_picker else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_sub(1);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.model_picker);
self.clamp_model_picker_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.model_picker)
}
pub(crate) fn model_picker_down(&mut self, visible_rows: usize) -> bool {
let Some(picker) = &mut self.modals.model_picker else {
return false;
};
let before_selected = picker.selected;
picker.selected = picker.selected.saturating_add(1);
picker.clamp();
let selected = picker.selected;
let before_offset = self.scroll_offset(&self.scroll_views.model_picker);
self.clamp_model_picker_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.model_picker)
}
fn clamp_model_picker_scroll(&self, visible_rows: usize) {
let Some(picker) = &self.modals.model_picker else {
return;
};
let visible_rows = visible_rows.max(1);
let max_offset = picker.entries.len().saturating_sub(visible_rows);
let mut offset = self
.scroll_offset(&self.scroll_views.model_picker)
.min(max_offset);
if picker.selected < offset {
offset = picker.selected;
} else if picker.selected >= offset.saturating_add(visible_rows) {
offset = picker.selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.model_picker, offset.min(max_offset));
}
pub(crate) fn selected_model_picker_id(&self) -> Option<String> {
self.modals
.model_picker
.as_ref()
.and_then(|picker| picker.entries.get(picker.selected))
.map(|entry| entry.id.clone())
}
pub(crate) fn open_models_modal_scoped(
&mut self,
rows: Vec<ModelToggleRow>,
_visible: usize,
scope: SettingsScope,
label: String,
loading: bool,
) {
let mut modal = ModelsModalState {
rows,
selected: 0,
settings_scope: scope,
scope_label: label,
loading,
};
modal.clamp();
self.modals.models_modal = Some(modal);
self.set_scroll_offset(&self.scroll_views.models_modal, 0);
self.hide_autocomplete();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::state::MissionControlState;
fn row(id: &str, enabled: bool) -> ModelToggleRow {
ModelToggleRow {
id: id.to_string(),
provider: id.split('/').next().unwrap().to_string(),
display_name: id.to_string(),
enabled,
}
}
#[test]
fn models_modal_open_close_loading_and_empty_states() {
let mut state = MissionControlState::default();
state.open_models_modal_scoped(
Vec::new(),
5,
SettingsScope::Global,
"Global".to_string(),
true,
);
let modal = state.modals.models_modal.as_ref().unwrap();
assert!(modal.loading);
assert!(modal.rows.is_empty());
assert_eq!(modal.selected, 0);
assert_eq!(state.scroll_offset(&state.scroll_views.models_modal), 0);
state.set_models_rows(Vec::new(), false, 5);
let modal = state.modals.models_modal.as_ref().unwrap();
assert!(!modal.loading);
assert!(modal.rows.is_empty());
assert_eq!(state.toggle_selected_model_enabled(), None);
assert!(state.close_models_modal());
assert!(!state.models_modal_visible());
}
#[test]
fn usage_modal_open_result_close_clears_pending_request() {
let mut state = MissionControlState {
pending_usage_request_id: Some(7),
..Default::default()
};
state.open_usage_modal_loading();
assert!(state.usage_modal_visible());
assert!(state.modals.usage_modal.as_ref().unwrap().loading);
assert!(state.set_usage_modal_result(crate::tui::usage::UsageLoadResult::MissingCli));
let modal = state.modals.usage_modal.as_ref().unwrap();
assert!(!modal.loading);
assert_eq!(
modal.result,
Some(crate::tui::usage::UsageLoadResult::MissingCli)
);
assert!(state.close_usage_modal());
assert!(!state.usage_modal_visible());
assert_eq!(state.pending_usage_request_id, None);
}
#[test]
fn usage_modal_scroll_clamps_to_overflow() {
let mut state = MissionControlState::default();
let report = crate::tui::usage::UsageReport {
generated_at: Some("now".to_string()),
help: Vec::new(),
warning: None,
providers: vec![crate::tui::usage::UsageProviderRow {
provider: "p".to_string(),
label: "Provider".to_string(),
source: "source".to_string(),
plan: None,
status: "fresh".to_string(),
windows: vec![
crate::tui::usage::UsageWindowRow {
label: "window".to_string(),
kind: "rolling".to_string(),
percent_remaining_basis_points: 5000,
percent_used_basis_points: Some(5000),
reset_text: None,
resets_at: None,
spent_usd: None,
limit_usd: None,
};
4
],
}],
};
state.open_usage_modal_loading();
state.set_usage_modal_result(crate::tui::usage::UsageLoadResult::Loaded(report));
assert!(state.scroll_usage_down_by(100, 3, 80));
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 8);
assert!(state.scroll_usage_up_by(2, 3, 80));
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 6);
assert!(state.scroll_usage_up_by(u16::MAX, 3, 80));
assert_eq!(state.scroll_offset(&state.scroll_views.usage_modal), 0);
}
#[test]
fn models_modal_selection_moves_with_clamping() {
let mut state = MissionControlState::default();
state.open_models_modal_scoped(
vec![
row("openai/a", true),
row("openai/b", true),
row("openai/c", true),
],
2,
SettingsScope::Global,
"Global".to_string(),
false,
);
assert!(!state.models_modal_up(2));
assert_eq!(state.modals.models_modal.as_ref().unwrap().selected, 0);
assert!(state.models_modal_down(2));
assert!(state.models_modal_down(2));
assert!(!state.models_modal_down(2));
let modal = state.modals.models_modal.as_ref().unwrap();
assert_eq!(modal.selected, 2);
assert_eq!(state.scroll_offset(&state.scroll_views.models_modal), 1);
}
#[test]
fn models_modal_toggle_returns_id_and_new_enabled_state() {
let mut state = MissionControlState::default();
state.open_models_modal_scoped(
vec![row("openai/a", true), row("openai/b", false)],
5,
SettingsScope::Global,
"Global".to_string(),
false,
);
state.models_modal_down(5);
assert_eq!(
state.toggle_selected_model_enabled(),
Some(("openai/b".to_string(), true))
);
assert!(state.modals.models_modal.as_ref().unwrap().rows[1].enabled);
state.set_models_rows(vec![row("openai/a", true)], true, 5);
assert_eq!(state.toggle_selected_model_enabled(), None);
}
#[test]
fn models_modal_scope_replacement_rebuilds_rows_and_clamps_selection() {
let mut state = MissionControlState::default();
state.open_models_modal_scoped(
vec![
row("openai/a", true),
row("openai/b", true),
row("openai/c", true),
],
2,
SettingsScope::Global,
"Global".to_string(),
false,
);
state.models_modal_down(2);
state.models_modal_down(2);
state.set_models_modal_scope_rows(
SettingsScope::Project,
"Project".to_string(),
vec![row("openai/z", false)],
2,
);
let modal = state.modals.models_modal.as_ref().unwrap();
assert_eq!(modal.settings_scope, SettingsScope::Project);
assert_eq!(modal.scope_label, "Project");
assert_eq!(modal.rows, vec![row("openai/z", false)]);
assert_eq!(modal.selected, 0);
assert_eq!(state.scroll_offset(&state.scroll_views.models_modal), 0);
assert!(!modal.loading);
}
}