use crate::{config::SettingsScope, output::ActivityId, tui::single_line_field::SingleLineField};
use std::collections::HashMap;
use unicode_segmentation::UnicodeSegmentation;
fn clamp_picker_selection(selected: &mut usize, item_count: usize) {
*selected = (*selected).min(item_count.saturating_sub(1));
}
fn keep_picker_selection_visible(
selected: usize,
item_count: usize,
visible_rows: usize,
offset: &mut usize,
) {
let visible_rows = visible_rows.max(1);
let max_offset = item_count.saturating_sub(visible_rows);
*offset = (*offset).min(max_offset);
if selected < *offset {
*offset = selected;
} else if selected >= (*offset).saturating_add(visible_rows) {
*offset = selected
.saturating_add(1)
.saturating_sub(visible_rows)
.min(max_offset);
}
}
fn picker_selection_after_move(
selected: usize,
item_count: usize,
amount: usize,
down: bool,
visible_rows: usize,
mut offset: usize,
) -> (usize, usize) {
let last = item_count.saturating_sub(1);
let selected = if down {
selected.saturating_add(amount).min(last)
} else {
selected.saturating_sub(amount).min(last)
};
keep_picker_selection_visible(selected, item_count, visible_rows, &mut offset);
(selected, offset)
}
#[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, PartialEq, Eq)]
pub(crate) struct LogoutPickerState {
pub(crate) entries: Vec<LoginProviderEntry>,
pub(crate) selected: usize,
pub(crate) notice: Option<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 ConnectProviderStage {
ChooseProvider,
ConfigureCustom,
AnthropicGuidance,
OAuth,
ReplacementConfirmation,
Saving,
Success,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConnectProviderField {
Label,
BaseUrl,
ApiKeyEnvVar,
}
fn debug_safe_text(value: &str) -> String {
crate::output::redact_sensitive_text(value)
}
fn debug_safe_url(value: &str) -> String {
let value = debug_safe_text(value);
let Some((base, _query)) = value.split_once('?') else {
return value;
};
format!("{base}?<redacted>")
}
fn debug_safe_env_var(value: &str) -> &str {
if value.is_empty() {
"<empty>"
} else if crate::config::looks_like_secret_value(value) {
"<redacted>"
} else {
value
}
}
fn debug_safe_entries(entries: &[LoginProviderEntry]) -> Vec<(String, String, String, String)> {
entries
.iter()
.map(|entry| {
(
debug_safe_text(&entry.id),
debug_safe_text(&entry.label),
debug_safe_text(&entry.status),
debug_safe_text(&entry.description),
)
})
.collect()
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ConnectProviderReplacementState {
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 std::fmt::Debug for ConnectProviderReplacementState {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let api_key_env_var = self.api_key_env_var.as_deref().map(debug_safe_env_var);
formatter
.debug_struct("ConnectProviderReplacementState")
.field("provider_id", &debug_safe_text(&self.provider_id))
.field("existing_label", &debug_safe_text(&self.existing_label))
.field("label", &debug_safe_text(&self.label))
.field("base_url", &debug_safe_url(&self.base_url))
.field("api_key_env_var", &api_key_env_var)
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct ConnectProviderState {
pub(crate) stage: ConnectProviderStage,
pub(crate) entries: Vec<LoginProviderEntry>,
pub(crate) selected: usize,
pub(crate) selected_field: ConnectProviderField,
pub(crate) provider_id: Option<String>,
pub(crate) label: SingleLineField,
pub(crate) base_url: SingleLineField,
pub(crate) api_key_env_var: SingleLineField,
pub(crate) instructions: String,
pub(crate) url: String,
pub(crate) fallback_input: SingleLineField,
pub(crate) notice: Option<String>,
pub(crate) error: Option<String>,
pub(crate) success: Option<String>,
pub(crate) retry_stage: Option<ConnectProviderStage>,
pub(crate) replacement: Option<ConnectProviderReplacementState>,
pub(crate) runtime_refresh_pending: bool,
}
impl std::fmt::Debug for ConnectProviderState {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let entries = debug_safe_entries(&self.entries);
let provider_id = self.provider_id.as_deref().map(debug_safe_text);
let label = debug_safe_text(self.label.value());
let base_url = debug_safe_url(self.base_url.value());
let api_key_env_var = debug_safe_env_var(self.api_key_env_var.value());
let notice = self.notice.as_deref().map(debug_safe_text);
let error = self.error.as_deref().map(debug_safe_text);
let success = self.success.as_deref().map(debug_safe_text);
formatter
.debug_struct("ConnectProviderState")
.field("stage", &self.stage)
.field("entries", &entries)
.field("selected", &self.selected)
.field("selected_field", &self.selected_field)
.field("provider_id", &provider_id)
.field("label", &label)
.field("base_url", &base_url)
.field("api_key_env_var", &api_key_env_var)
.field("instructions", &"<redacted>")
.field("url", &"<redacted>")
.field("fallback_input", &"<redacted>")
.field("notice", ¬ice)
.field("error", &error)
.field("success", &success)
.field("retry_stage", &self.retry_stage)
.field("replacement", &self.replacement)
.field("runtime_refresh_pending", &self.runtime_refresh_pending)
.finish()
}
}
impl ConnectProviderState {
fn new(entries: Vec<LoginProviderEntry>, notice: Option<String>) -> Self {
Self {
stage: ConnectProviderStage::ChooseProvider,
entries,
selected: 0,
selected_field: ConnectProviderField::Label,
provider_id: None,
label: SingleLineField::plain(),
base_url: SingleLineField::plain(),
api_key_env_var: SingleLineField::plain(),
instructions: String::new(),
url: String::new(),
fallback_input: SingleLineField::redacted(),
notice,
error: None,
success: None,
retry_stage: None,
replacement: None,
runtime_refresh_pending: false,
}
}
fn clamp(&mut self) {
let item_count = self.entries.len();
clamp_picker_selection(&mut self.selected, item_count);
}
fn selected_provider_id(&self) -> Option<String> {
self.entries
.get(self.selected)
.map(|entry| entry.id.clone())
}
fn current_field_mut(&mut self) -> Option<&mut SingleLineField> {
match self.selected_field {
ConnectProviderField::Label => Some(&mut self.label),
ConnectProviderField::BaseUrl => Some(&mut self.base_url),
ConnectProviderField::ApiKeyEnvVar => Some(&mut self.api_key_env_var),
}
}
fn next_field(&mut self) {
self.selected_field = match self.selected_field {
ConnectProviderField::Label => ConnectProviderField::BaseUrl,
ConnectProviderField::BaseUrl => ConnectProviderField::ApiKeyEnvVar,
ConnectProviderField::ApiKeyEnvVar => ConnectProviderField::Label,
};
}
fn previous_field(&mut self) {
self.selected_field = match self.selected_field {
ConnectProviderField::Label => ConnectProviderField::ApiKeyEnvVar,
ConnectProviderField::BaseUrl => ConnectProviderField::Label,
ConnectProviderField::ApiKeyEnvVar => ConnectProviderField::BaseUrl,
};
}
}
impl LogoutPickerState {
pub(super) fn clamp(&mut self) {
let item_count = self.entries.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.entries.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub(crate) enum ThemeSaveState {
#[default]
Idle,
Pending,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ThemePickerState {
pub(crate) entries: Vec<crate::appearance::ThemeCatalogEntry>,
pub(crate) filtered_indices: Vec<usize>,
pub(crate) query: String,
pub(crate) query_cursor: usize,
pub(crate) selected: usize,
pub(crate) preview_theme_id: String,
pub(crate) opening_revision: u64,
pub(crate) loading: bool,
pub(crate) notice: Option<String>,
pub(crate) error: Option<String>,
pub(crate) catalog_request_id: Option<u64>,
pub(crate) save_state: ThemeSaveState,
}
impl ThemePickerState {
fn new(
preview_theme_id: String,
opening_revision: u64,
loading: bool,
catalog_request_id: Option<u64>,
) -> Self {
Self {
entries: Vec::new(),
filtered_indices: Vec::new(),
query: String::new(),
query_cursor: 0,
selected: 0,
preview_theme_id,
opening_revision,
loading,
notice: None,
error: None,
catalog_request_id,
save_state: ThemeSaveState::Idle,
}
}
fn selected_entry(&self) -> Option<&crate::appearance::ThemeCatalogEntry> {
self.filtered_indices
.get(self.selected)
.and_then(|index| self.entries.get(*index))
}
fn selected_id(&self) -> Option<String> {
self.selected_entry().map(|entry| entry.id.clone())
}
fn clamp(&mut self) {
let item_count = self.filtered_indices.len();
clamp_picker_selection(&mut self.selected, item_count);
}
fn refilter(&mut self) {
let selected_id = self.selected_id();
let needle = self.query.to_lowercase();
self.filtered_indices = self
.entries
.iter()
.enumerate()
.filter(|(_, entry)| {
needle.is_empty()
|| entry.id.to_lowercase().contains(&needle)
|| entry.display_name.to_lowercase().contains(&needle)
})
.map(|(index, _)| index)
.collect();
self.selected = selected_id
.and_then(|id| {
self.filtered_indices
.iter()
.position(|index| self.entries.get(*index).is_some_and(|entry| entry.id == id))
})
.unwrap_or(0);
self.clamp();
}
fn set_catalog(
&mut self,
entries: Vec<crate::appearance::ThemeCatalogEntry>,
notice: Option<String>,
) {
self.entries = entries;
self.loading = false;
self.error = None;
self.notice = notice;
self.catalog_request_id = None;
self.refilter();
if let Some(index) = self.filtered_indices.iter().position(|index| {
self.entries
.get(*index)
.is_some_and(|entry| entry.id == self.preview_theme_id)
}) {
self.selected = index;
}
}
fn set_error(&mut self, error: String) {
self.loading = false;
self.catalog_request_id = None;
self.error = Some(error);
}
fn edit_query(&mut self, edit: impl FnOnce(&mut String, &mut usize)) -> Option<String> {
if self.loading
|| !matches!(
self.save_state,
ThemeSaveState::Idle | ThemeSaveState::Failed(_)
)
{
return None;
}
edit(&mut self.query, &mut self.query_cursor);
self.refilter();
self.selected_id()
}
fn move_selection(&mut self, amount: usize, down: bool, visible_rows: usize) -> Option<String> {
if self.loading
|| !matches!(
self.save_state,
ThemeSaveState::Idle | ThemeSaveState::Failed(_)
)
{
return None;
}
if !self.filtered_indices.is_empty() {
if down {
self.selected = self
.selected
.saturating_add(amount)
.min(self.filtered_indices.len() - 1);
} else {
self.selected = self.selected.saturating_sub(amount);
}
}
self.clamp();
let _ = visible_rows;
self.selected_id()
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) {
let item_count = self.rows.len();
clamp_picker_selection(&mut self.selected, item_count);
}
}
#[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) plan_previews: Vec<String>,
pub(crate) selected: usize,
pub(crate) dry_run: bool,
pub(crate) mode: crate::checkpoints::RewindMode,
pub(crate) show_mode_choices: bool,
}
impl RewindModalState {
pub(crate) fn display_text(&self) -> String {
let help = if self.plan.is_some() || !self.plans.is_empty() {
"[Enter] apply rewind • [Esc] cancel • [Up]/[Down] select/scroll"
} else {
"[Esc] close • [Up]/[Down] scroll"
};
if !self.show_mode_choices {
return format!("{help}\n\n{}", self.content);
}
let effect = match self.mode {
crate::checkpoints::RewindMode::Conversation => {
"Fork before this prompt; restore prompt for editing. Files unchanged."
}
crate::checkpoints::RewindMode::Files => {
"Restore tracked files from this prompt onward. Conversation unchanged."
}
crate::checkpoints::RewindMode::Both => {
"Fork before this prompt and restore tracked files. Restore prompt for editing."
}
};
let prefix = self
.plans
.get(self.selected)
.map(|plan| {
format!(
"Target {}/{}: turn {}\n\n",
self.selected + 1,
self.plans.len(),
plan.target_turn
)
})
.unwrap_or_default();
format!(
"{help} • {}\n\nModes: conversation only | files only | both\nSelected: {}\n{effect}\nOriginal conversation remains in the session picker.\n\n{prefix}{}",
crate::tui::input::REWIND_MODE_HELP,
self.mode.label(),
self.content
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModalKind {
SubagentViewer,
SessionPicker,
Skills,
Mcp,
Tools,
Subagents,
Models,
Usage,
SystemPrompt,
Rewind,
ConnectProvider,
LogoutConfirmation,
LogoutPicker,
ModelPicker,
ThemePicker,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentViewerTabState {
pub(crate) offset: usize,
pub(crate) follow_live: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SubagentViewerState {
pub(crate) batch_id: ActivityId,
pub(crate) selected_task_id: ActivityId,
pub(crate) tab_states: HashMap<ActivityId, SubagentViewerTabState>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct ModalState {
pub(crate) model_picker: Option<ModelPickerState>,
pub(crate) connect_provider: Option<ConnectProviderState>,
pub(crate) logout_picker: Option<LogoutPickerState>,
pub(crate) logout_confirmation: Option<LogoutConfirmationState>,
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>,
pub(crate) subagent_viewer: Option<SubagentViewerState>,
pub(crate) theme_picker: Option<ThemePickerState>,
}
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 top_modal(&self) -> Option<ModalKind> {
if self.modals.subagent_viewer.is_some() {
Some(ModalKind::SubagentViewer)
} else if self.modals.session_picker.is_some() {
Some(ModalKind::SessionPicker)
} else if self.modals.skills_modal.is_some() {
Some(ModalKind::Skills)
} else if self.modals.mcp_modal.is_some() {
Some(ModalKind::Mcp)
} else if self.modals.tools_modal.is_some() {
Some(ModalKind::Tools)
} else if self.modals.subagents_modal.is_some() {
Some(ModalKind::Subagents)
} else if self.modals.models_modal.is_some() {
Some(ModalKind::Models)
} else if self.modals.usage_modal.is_some() {
Some(ModalKind::Usage)
} else if self.modals.system_prompt_modal.is_some() {
Some(ModalKind::SystemPrompt)
} else if self.modals.rewind_modal.is_some() {
Some(ModalKind::Rewind)
} else if self.modals.connect_provider.is_some() {
Some(ModalKind::ConnectProvider)
} else if self.modals.logout_confirmation.is_some() {
Some(ModalKind::LogoutConfirmation)
} else if self.modals.logout_picker.is_some() {
Some(ModalKind::LogoutPicker)
} else if self.modals.model_picker.is_some() {
Some(ModalKind::ModelPicker)
} else if self.modals.theme_picker.is_some() {
Some(ModalKind::ThemePicker)
} else {
None
}
}
pub(crate) fn theme_picker_preview_id(&self) -> Option<&str> {
self.modals
.theme_picker
.as_ref()
.map(|picker| picker.preview_theme_id.as_str())
}
pub(crate) fn open_theme_picker(
&mut self,
current_theme_id: String,
opening_revision: u64,
loading: bool,
catalog_request_id: Option<u64>,
entries: Vec<crate::appearance::ThemeCatalogEntry>,
notice: Option<String>,
) {
let mut picker = ThemePickerState::new(
current_theme_id,
opening_revision,
loading,
catalog_request_id,
);
if !loading {
picker.set_catalog(entries, notice);
}
self.modals.theme_picker = Some(picker);
self.set_scroll_offset(&self.scroll_views.theme_modal, 0);
self.hide_autocomplete();
}
pub(crate) fn close_theme_picker(&mut self) -> bool {
self.modals.theme_picker.take().is_some()
}
pub(crate) fn theme_picker_visible(&self) -> bool {
self.modals.theme_picker.is_some()
}
pub(crate) fn theme_picker_selected_id(&self) -> Option<String> {
self.modals
.theme_picker
.as_ref()
.and_then(ThemePickerState::selected_id)
}
pub(crate) fn theme_picker_up(&mut self, visible_rows: usize) -> Option<String> {
self.theme_picker_move(1, false, visible_rows)
}
pub(crate) fn theme_picker_down(&mut self, visible_rows: usize) -> Option<String> {
self.theme_picker_move(1, true, visible_rows)
}
pub(crate) fn theme_picker_page_up(&mut self, visible_rows: usize) -> Option<String> {
self.theme_picker_move(visible_rows.max(1), false, visible_rows)
}
pub(crate) fn theme_picker_page_down(&mut self, visible_rows: usize) -> Option<String> {
self.theme_picker_move(visible_rows.max(1), true, visible_rows)
}
fn theme_picker_move(
&mut self,
amount: usize,
down: bool,
visible_rows: usize,
) -> Option<String> {
let selected =
self.modals
.theme_picker
.as_mut()?
.move_selection(amount, down, visible_rows);
self.clamp_theme_picker_scroll(visible_rows);
selected
}
pub(crate) fn theme_picker_insert_char(&mut self, ch: char) -> Option<String> {
if ch.is_control() {
return None;
}
let selected = self
.modals
.theme_picker
.as_mut()?
.edit_query(|query, cursor| {
query.insert(*cursor, ch);
*cursor += ch.len_utf8();
});
self.clamp_theme_picker_scroll(1);
selected
}
pub(crate) fn theme_picker_backspace(&mut self) -> Option<String> {
let selected = self
.modals
.theme_picker
.as_mut()?
.edit_query(|query, cursor| {
if let Some((previous, _)) = query[..*cursor].grapheme_indices(true).next_back() {
query.drain(previous..*cursor);
*cursor = previous;
}
});
self.clamp_theme_picker_scroll(1);
selected
}
pub(crate) fn theme_picker_delete(&mut self) -> Option<String> {
let selected = self
.modals
.theme_picker
.as_mut()?
.edit_query(|query, cursor| {
if let Some(grapheme) = query[*cursor..].graphemes(true).next() {
query.drain(*cursor..*cursor + grapheme.len());
}
});
self.clamp_theme_picker_scroll(1);
selected
}
pub(crate) fn theme_picker_cursor_left(&mut self) {
if self.theme_picker_save_pending() {
return;
}
if let Some(picker) = self.modals.theme_picker.as_mut()
&& picker.query_cursor > 0
{
picker.query_cursor = picker.query[..picker.query_cursor]
.grapheme_indices(true)
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
pub(crate) fn theme_picker_cursor_right(&mut self) {
if self.theme_picker_save_pending() {
return;
}
if let Some(picker) = self.modals.theme_picker.as_mut()
&& let Some(grapheme) = picker.query[picker.query_cursor..].graphemes(true).next()
{
picker.query_cursor += grapheme.len();
}
}
pub(crate) fn theme_picker_cursor_home(&mut self) {
if self.theme_picker_save_pending() {
return;
}
if let Some(picker) = self.modals.theme_picker.as_mut() {
picker.query_cursor = 0;
}
}
pub(crate) fn theme_picker_cursor_end(&mut self) {
if self.theme_picker_save_pending() {
return;
}
if let Some(picker) = self.modals.theme_picker.as_mut() {
picker.query_cursor = picker.query.len();
}
}
pub(crate) fn set_theme_picker_catalog(
&mut self,
entries: Vec<crate::appearance::ThemeCatalogEntry>,
notice: Option<String>,
visible_rows: usize,
) -> bool {
let Some(picker) = self.modals.theme_picker.as_mut() else {
return false;
};
picker.set_catalog(entries, notice);
self.clamp_theme_picker_scroll(visible_rows);
true
}
pub(crate) fn set_theme_picker_error(&mut self, error: String) -> bool {
let Some(picker) = self.modals.theme_picker.as_mut() else {
return false;
};
picker.set_error(error);
true
}
pub(crate) fn set_theme_picker_save_pending(&mut self) -> bool {
let Some(picker) = self.modals.theme_picker.as_mut() else {
return false;
};
picker.save_state = ThemeSaveState::Pending;
true
}
pub(crate) fn set_theme_picker_save_failed(&mut self, error: String) -> bool {
let Some(picker) = self.modals.theme_picker.as_mut() else {
return false;
};
picker.save_state = ThemeSaveState::Failed(error);
true
}
pub(crate) fn theme_picker_save_pending(&self) -> bool {
self.modals
.theme_picker
.as_ref()
.is_some_and(|picker| matches!(picker.save_state, ThemeSaveState::Pending))
}
pub(crate) fn theme_picker_catalog_request_id(&self) -> Option<u64> {
self.modals
.theme_picker
.as_ref()
.and_then(|picker| picker.catalog_request_id)
}
fn clamp_theme_picker_scroll(&self, visible_rows: usize) {
let Some(picker) = &self.modals.theme_picker else {
return;
};
let mut offset = self.scroll_offset(&self.scroll_views.theme_modal);
keep_picker_selection_visible(
picker.selected,
picker.filtered_indices.len(),
visible_rows,
&mut offset,
);
self.set_scroll_offset(&self.scroll_views.theme_modal, offset);
}
}
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 {
self.move_session_picker(1, false, visible_rows)
}
pub(crate) fn session_picker_down(&mut self, visible_rows: usize) -> bool {
self.move_session_picker(1, true, visible_rows)
}
pub(crate) fn session_picker_page_up(&mut self, visible_rows: usize) -> bool {
let rows = visible_rows.max(1);
self.move_session_picker(rows, false, rows)
}
pub(crate) fn session_picker_page_down(&mut self, visible_rows: usize) -> bool {
let rows = visible_rows.max(1);
self.move_session_picker(rows, true, rows)
}
fn move_session_picker(&mut self, amount: usize, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.session_list);
let (before_selected, selected, offset) = {
let Some(picker) = self.modals.session_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
let (selected, offset) = picker_selection_after_move(
picker.selected,
picker.rows.len(),
amount,
down,
visible_rows,
before_offset,
);
picker.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.session_list, offset);
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())
}
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 (before_selected, selected, offset) = {
let Some(modal) = self.modals.skills_modal.as_mut() else {
return false;
};
let before_selected = modal.selected;
let (selected, offset) = picker_selection_after_move(
modal.selected,
modal.rows.len(),
1,
down,
visible_rows,
before_offset,
);
modal.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.skills_modal, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.skills_modal)
}
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 (before_selected, selected, offset) = {
let Some(modal) = self.modals.mcp_modal.as_mut() else {
return false;
};
let before_selected = modal.selected;
let (selected, offset) = picker_selection_after_move(
modal.selected,
modal.rows.len(),
1,
down,
visible_rows,
before_offset,
);
modal.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.mcp_modal, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.mcp_modal)
}
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 (before_selected, selected, offset) = {
let Some(modal) = self.modals.tools_modal.as_mut() else {
return false;
};
let before_selected = modal.selected;
let (selected, offset) = picker_selection_after_move(
modal.selected,
modal.rows.len(),
1,
down,
visible_rows,
before_offset,
);
modal.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.tools_modal, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.tools_modal)
}
#[cfg(test)]
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 (before_selected, selected, offset) = {
let Some(modal) = self.modals.subagents_modal.as_mut() else {
return false;
};
let before_selected = modal.selected;
let (selected, offset) = picker_selection_after_move(
modal.selected,
modal.rows.len(),
1,
down,
visible_rows,
before_offset,
);
modal.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.subagents_modal, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.subagents_modal)
}
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 {
self.move_models_modal(false, visible_rows)
}
pub(crate) fn models_modal_down(&mut self, visible_rows: usize) -> bool {
self.move_models_modal(true, visible_rows)
}
fn move_models_modal(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.models_modal);
let (before_selected, selected, offset) = {
let Some(modal) = self.modals.models_modal.as_mut() else {
return false;
};
let before_selected = modal.selected;
let (selected, offset) = picker_selection_after_move(
modal.selected,
modal.rows.len(),
1,
down,
visible_rows,
before_offset,
);
modal.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.models_modal, offset);
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 mut offset = self.scroll_offset(&self.scroll_views.models_modal);
keep_picker_selection_visible(modal.selected, modal.rows.len(), visible_rows, &mut offset);
self.set_scroll_offset(&self.scroll_views.models_modal, offset);
}
#[cfg(test)]
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 {
show_mode_choices: plan.is_some(),
content,
plan,
plans: Vec::new(),
plan_previews: Vec::new(),
selected: 0,
dry_run,
mode: crate::checkpoints::RewindMode::default(),
});
self.set_scroll_offset(&self.scroll_views.rewind_modal, 0);
self.hide_autocomplete();
}
#[cfg(test)]
pub(crate) fn open_rewind_picker(&mut self, plans: Vec<crate::checkpoints::RestorePlan>) {
let previews = plans
.iter()
.map(crate::shell::format_rewind_plan_for_tui)
.collect();
self.open_rewind_picker_with_previews(plans, previews);
}
pub(crate) fn open_rewind_picker_with_previews(
&mut self,
plans: Vec<crate::checkpoints::RestorePlan>,
plan_previews: Vec<String>,
) {
let content = plan_previews
.first()
.cloned()
.unwrap_or_else(|| "no rewindable file changes".to_string());
self.modals.rewind_modal = Some(RewindModalState {
show_mode_choices: true,
content,
plan: None,
plans,
plan_previews,
selected: 0,
dry_run: false,
mode: crate::checkpoints::RewindMode::default(),
});
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);
if before != modal.selected
&& let Some(preview) = modal.plan_previews.get(modal.selected)
{
modal.content = preview.clone();
}
before != modal.selected
};
if changed {
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);
if before != modal.selected
&& let Some(preview) = modal.plan_previews.get(modal.selected)
{
modal.content = preview.clone();
}
before != modal.selected
};
if changed {
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,
crate::checkpoints::RewindMode,
)> {
self.modals.rewind_modal.take().and_then(|modal| {
modal
.plan
.or_else(|| modal.plans.get(modal.selected).cloned())
.map(|plan| (plan, modal.mode))
})
}
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.display_text(), wrap_width.max(1))
.saturating_sub(visible_rows.max(1) as usize)
}
pub(crate) fn open_connect_provider(
&mut self,
entries: Vec<LoginProviderEntry>,
notice: Option<String>,
) {
let mut connect = ConnectProviderState::new(entries, notice);
connect.clamp();
self.modals.connect_provider = Some(connect);
self.set_scroll_offset(&self.scroll_views.connect_provider, 0);
self.hide_autocomplete();
}
pub(crate) fn close_connect_provider(&mut self) -> bool {
self.modals.connect_provider.take().is_some()
}
pub(crate) fn connect_provider_up(&mut self, visible_rows: usize) -> bool {
self.move_connect_provider_selection(-1, visible_rows)
}
pub(crate) fn connect_provider_down(&mut self, visible_rows: usize) -> bool {
self.move_connect_provider_selection(1, visible_rows)
}
fn move_connect_provider_selection(&mut self, amount: i32, visible_rows: usize) -> bool {
let (before_selected, selected) = {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage != ConnectProviderStage::ChooseProvider {
return false;
}
let before_selected = connect.selected;
if amount.is_negative() {
connect.selected = connect
.selected
.saturating_sub(amount.unsigned_abs() as usize);
} else {
connect.selected = connect.selected.saturating_add(amount as usize);
}
connect.clamp();
(before_selected, connect.selected)
};
let before_offset = self.scroll_offset(&self.scroll_views.connect_provider);
self.clamp_connect_provider_scroll(visible_rows);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.connect_provider)
}
fn clamp_connect_provider_scroll(&self, visible_rows: usize) {
let Some(connect) = &self.modals.connect_provider else {
return;
};
let visible_rows = visible_rows.max(1);
let max_offset = connect.entries.len().saturating_sub(visible_rows);
let mut offset = self
.scroll_offset(&self.scroll_views.connect_provider)
.min(max_offset);
if connect.selected < offset {
offset = connect.selected;
} else if connect.selected >= offset.saturating_add(visible_rows) {
offset = connect.selected + 1 - visible_rows;
}
self.set_scroll_offset(&self.scroll_views.connect_provider, offset.min(max_offset));
}
pub(crate) fn selected_connect_provider_id(&self) -> Option<String> {
self.modals
.connect_provider
.as_ref()
.and_then(ConnectProviderState::selected_provider_id)
}
pub(crate) fn start_connect_custom_configuration(&mut self) {
let Some(connect) = &mut self.modals.connect_provider else {
return;
};
connect.stage = ConnectProviderStage::ConfigureCustom;
connect.provider_id = None;
connect.selected_field = ConnectProviderField::Label;
connect.label.clear();
connect.base_url.clear();
connect.api_key_env_var.clear();
connect.instructions.clear();
connect.url.clear();
connect.fallback_input.clear();
connect.notice = None;
connect.error = None;
connect.success = None;
connect.retry_stage = None;
connect.replacement = None;
connect.runtime_refresh_pending = false;
}
pub(crate) fn start_connect_anthropic_guidance(&mut self) {
if let Some(connect) = &mut self.modals.connect_provider {
connect.stage = ConnectProviderStage::AnthropicGuidance;
connect.provider_id = Some(crate::providers::ANTHROPIC_PROVIDER.to_string());
connect.instructions = crate::login::anthropic_api_key_setup_guidance().to_string();
connect.error = None;
connect.success = None;
connect.retry_stage = Some(ConnectProviderStage::AnthropicGuidance);
}
}
pub(crate) fn start_connect_oauth(&mut self, provider_id: String) {
if let Some(connect) = &mut self.modals.connect_provider {
connect.stage = ConnectProviderStage::OAuth;
connect.provider_id = Some(provider_id);
connect.instructions = "Starting login…".to_string();
connect.url.clear();
connect.fallback_input.clear();
connect.error = None;
connect.success = None;
connect.retry_stage = Some(ConnectProviderStage::OAuth);
connect.runtime_refresh_pending = false;
}
}
pub(crate) fn connect_provider_next_field(&mut self) {
if let Some(connect) = &mut self.modals.connect_provider
&& connect.stage == ConnectProviderStage::ConfigureCustom
{
connect.next_field();
connect.error = None;
}
}
pub(crate) fn connect_provider_previous_field(&mut self) {
if let Some(connect) = &mut self.modals.connect_provider
&& connect.stage == ConnectProviderStage::ConfigureCustom
{
connect.previous_field();
connect.error = None;
}
}
pub(crate) fn set_connect_provider_instructions(
&mut self,
url: String,
instructions: String,
) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage != ConnectProviderStage::OAuth {
return false;
}
connect.url = url;
connect.instructions = instructions;
connect.fallback_input.clear();
true
}
pub(crate) fn set_connect_provider_instructions_for(
&mut self,
worker_id: u64,
url: String,
instructions: String,
) -> bool {
if self.active_worker_id != Some(worker_id) {
return false;
}
self.set_connect_provider_instructions(url, instructions)
}
fn active_modal_field_mut(&mut self) -> Option<&mut SingleLineField> {
let connect = self.modals.connect_provider.as_mut()?;
match connect.stage {
ConnectProviderStage::ConfigureCustom => connect.current_field_mut(),
ConnectProviderStage::OAuth => Some(&mut connect.fallback_input),
_ => None,
}
}
fn finish_modal_field_edit(&mut self) {
if let Some(connect) = &mut self.modals.connect_provider
&& connect.stage == ConnectProviderStage::ConfigureCustom
{
connect.error = None;
if connect.selected_field == ConnectProviderField::Label {
connect.provider_id =
crate::config::derive_custom_provider_id(connect.label.value()).ok();
}
}
}
pub(crate) fn open_connect_provider_replacement_confirmation(
&mut self,
provider_id: String,
existing_label: String,
label: String,
base_url: String,
api_key_env_var: Option<String>,
) {
if let Some(connect) = &mut self.modals.connect_provider {
connect.stage = ConnectProviderStage::ReplacementConfirmation;
connect.provider_id = Some(provider_id.clone());
connect.replacement = Some(ConnectProviderReplacementState {
provider_id,
existing_label,
label,
base_url,
api_key_env_var,
});
connect.error = None;
}
}
pub(crate) fn set_connect_provider_saving(&mut self, provider_id: String) {
if let Some(connect) = &mut self.modals.connect_provider {
connect.stage = ConnectProviderStage::Saving;
connect.provider_id = Some(provider_id);
connect.replacement = None;
connect.error = None;
connect.success = None;
connect.retry_stage = Some(ConnectProviderStage::ConfigureCustom);
connect.runtime_refresh_pending = false;
}
}
pub(crate) fn set_connect_provider_success(
&mut self,
provider_id: &str,
message: String,
runtime_refresh_pending: bool,
) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.provider_id.as_deref() != Some(provider_id)
|| !matches!(
connect.stage,
ConnectProviderStage::OAuth | ConnectProviderStage::Saving
)
{
return false;
}
connect.stage = ConnectProviderStage::Success;
connect.success = Some(crate::output::redact_sensitive_text(&message));
connect.error = None;
connect.retry_stage = None;
connect.runtime_refresh_pending = runtime_refresh_pending;
connect.fallback_input.clear();
true
}
pub(crate) fn set_connect_provider_error(&mut self, error: impl Into<String>) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage == ConnectProviderStage::ChooseProvider {
connect.retry_stage = Some(ConnectProviderStage::ChooseProvider);
} else if connect.retry_stage.is_none() {
connect.retry_stage = Some(match connect.stage {
ConnectProviderStage::Saving => ConnectProviderStage::ConfigureCustom,
ConnectProviderStage::OAuth => ConnectProviderStage::OAuth,
ConnectProviderStage::AnthropicGuidance => ConnectProviderStage::AnthropicGuidance,
_ => ConnectProviderStage::ChooseProvider,
});
}
connect.stage = ConnectProviderStage::Error;
connect.error = Some(crate::output::redact_sensitive_text(&error.into()));
connect.success = None;
connect.fallback_input.clear();
connect.runtime_refresh_pending = false;
true
}
pub(crate) fn take_connect_provider_runtime_refresh(&mut self, provider_id: &str) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage == ConnectProviderStage::Success
&& connect.provider_id.as_deref() == Some(provider_id)
&& connect.runtime_refresh_pending
{
connect.runtime_refresh_pending = false;
true
} else {
false
}
}
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()
}
fn reject_secret_like_api_key_env_var(&mut self, text: &str) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage != ConnectProviderStage::ConfigureCustom
|| connect.selected_field != ConnectProviderField::ApiKeyEnvVar
{
return false;
}
let mut candidate = connect.api_key_env_var.clone();
candidate.insert_str(text);
if crate::config::looks_like_secret_value(candidate.value()) {
connect.error = Some(
"API key environment variable name looks like a secret value; enter a variable name such as CUSTOM_PROVIDER_API_KEY".to_string(),
);
true
} else {
false
}
}
pub(crate) fn insert_modal_field_char(&mut self, ch: char) {
if self.reject_secret_like_api_key_env_var(&ch.to_string()) {
return;
}
if self
.active_modal_field_mut()
.is_some_and(|field| field.insert_char(ch))
{
self.finish_modal_field_edit();
}
}
pub(crate) fn insert_modal_field_text(&mut self, text: &str) -> bool {
if self.reject_secret_like_api_key_env_var(text) {
return false;
}
let changed = self
.active_modal_field_mut()
.is_some_and(|field| field.insert_str(text));
if changed {
self.finish_modal_field_edit();
}
changed
}
pub(crate) fn backspace_modal_field(&mut self) {
if self
.active_modal_field_mut()
.is_some_and(SingleLineField::backspace)
{
self.finish_modal_field_edit();
}
}
pub(crate) fn delete_modal_field_forward(&mut self) {
if self
.active_modal_field_mut()
.is_some_and(SingleLineField::delete_forward)
{
self.finish_modal_field_edit();
}
}
pub(crate) fn move_modal_field_left(&mut self) {
if let Some(field) = self.active_modal_field_mut() {
field.move_left();
}
}
pub(crate) fn move_modal_field_right(&mut self) {
if let Some(field) = self.active_modal_field_mut() {
field.move_right();
}
}
pub(crate) fn move_modal_field_home(&mut self) {
if let Some(field) = self.active_modal_field_mut() {
field.move_home();
}
}
pub(crate) fn move_modal_field_end(&mut self) {
if let Some(field) = self.active_modal_field_mut() {
field.move_end();
}
}
pub(crate) fn take_connect_provider_fallback_input(&mut self) -> Option<String> {
self.modals.connect_provider.as_mut()?.fallback_input.take()
}
pub(crate) fn connect_provider_stage(&self) -> Option<ConnectProviderStage> {
self.modals
.connect_provider
.as_ref()
.map(|connect| connect.stage)
}
pub(crate) fn set_connect_provider_form_error(&mut self, error: impl Into<String>) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
if connect.stage != ConnectProviderStage::ConfigureCustom {
return false;
}
connect.error = Some(crate::output::redact_sensitive_text(&error.into()));
true
}
pub(crate) fn prepare_connect_provider_retry(&mut self) -> Option<ConnectProviderStage> {
let connect = self.modals.connect_provider.as_mut()?;
let stage = connect.retry_stage?;
connect.stage = stage;
connect.error = None;
connect.success = None;
connect.replacement = None;
connect.fallback_input.clear();
Some(stage)
}
pub(crate) fn back_connect_provider(&mut self) -> bool {
let Some(connect) = &mut self.modals.connect_provider else {
return false;
};
let target = match connect.stage {
ConnectProviderStage::ChooseProvider => ConnectProviderStage::ChooseProvider,
ConnectProviderStage::ConfigureCustom => ConnectProviderStage::ChooseProvider,
ConnectProviderStage::AnthropicGuidance => ConnectProviderStage::ChooseProvider,
ConnectProviderStage::OAuth => ConnectProviderStage::ChooseProvider,
ConnectProviderStage::ReplacementConfirmation => ConnectProviderStage::ConfigureCustom,
ConnectProviderStage::Saving => ConnectProviderStage::ConfigureCustom,
ConnectProviderStage::Success => ConnectProviderStage::ChooseProvider,
ConnectProviderStage::Error => match connect.retry_stage {
Some(ConnectProviderStage::ConfigureCustom)
| Some(ConnectProviderStage::ReplacementConfirmation)
| Some(ConnectProviderStage::Saving) => ConnectProviderStage::ConfigureCustom,
_ => ConnectProviderStage::ChooseProvider,
},
};
connect.stage = target;
connect.error = None;
connect.success = None;
connect.replacement = None;
connect.fallback_input.clear();
if target == ConnectProviderStage::ChooseProvider {
connect.provider_id = None;
}
true
}
pub(crate) fn open_logout_picker(
&mut self,
entries: Vec<LoginProviderEntry>,
notice: Option<String>,
_visible_rows: usize,
) {
let mut picker = LogoutPickerState {
entries,
selected: 0,
notice,
};
picker.clamp();
self.modals.logout_picker = Some(picker);
self.set_scroll_offset(&self.scroll_views.logout_picker, 0);
self.hide_autocomplete();
}
pub(crate) fn close_logout_picker(&mut self) -> bool {
self.modals.logout_picker.take().is_some()
}
pub(crate) fn logout_picker_up(&mut self, visible_rows: usize) -> bool {
self.move_logout_picker(false, visible_rows)
}
pub(crate) fn logout_picker_down(&mut self, visible_rows: usize) -> bool {
self.move_logout_picker(true, visible_rows)
}
fn move_logout_picker(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.logout_picker);
let (before_selected, selected, offset) = {
let Some(picker) = self.modals.logout_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
let (selected, offset) = picker_selection_after_move(
picker.selected,
picker.entries.len(),
1,
down,
visible_rows,
before_offset,
);
picker.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.logout_picker, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.logout_picker)
}
pub(crate) fn selected_logout_provider_id(&self) -> Option<String> {
self.modals
.logout_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 {
self.move_model_picker(false, visible_rows)
}
pub(crate) fn model_picker_down(&mut self, visible_rows: usize) -> bool {
self.move_model_picker(true, visible_rows)
}
fn move_model_picker(&mut self, down: bool, visible_rows: usize) -> bool {
let before_offset = self.scroll_offset(&self.scroll_views.model_picker);
let (before_selected, selected, offset) = {
let Some(picker) = self.modals.model_picker.as_mut() else {
return false;
};
let before_selected = picker.selected;
let (selected, offset) = picker_selection_after_move(
picker.selected,
picker.entries.len(),
1,
down,
visible_rows,
before_offset,
);
picker.selected = selected;
(before_selected, selected, offset)
};
self.set_scroll_offset(&self.scroll_views.model_picker, offset);
before_selected != selected
|| before_offset != self.scroll_offset(&self.scroll_views.model_picker)
}
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);
}
}
#[test]
fn theme_picker_lifecycle_blocks_pending_edits() {
let mut state = MissionControlState::default();
state.open_theme_picker("builtin:default".into(), 1, false, None, Vec::new(), None);
assert!(state.set_theme_picker_save_pending());
assert!(state.theme_picker_up(5).is_none());
assert!(state.theme_picker_insert_char('x').is_none());
assert_eq!(state.theme_picker_preview_id(), Some("builtin:default"));
}
#[test]
fn theme_picker_failure_allows_retry() {
let mut state = MissionControlState::default();
state.open_theme_picker("builtin:default".into(), 1, false, None, Vec::new(), None);
state.set_theme_picker_save_failed("disk full".into());
assert!(!state.theme_picker_save_pending());
assert!(state.theme_picker_insert_char('x').is_none());
}
#[test]
fn theme_picker_empty_catalog_is_safe() {
let mut state = MissionControlState::default();
state.open_theme_picker("missing-custom".into(), 1, false, None, Vec::new(), None);
assert!(state.theme_picker_selected_id().is_none());
assert!(state.theme_picker_down(0).is_none());
}