mod bash_approval;
mod capability_picker;
mod connect_provider;
mod fields;
mod login_logout;
mod model_picker;
mod picker;
mod rewind;
mod session_picker;
mod system_prompt;
mod theme_picker;
mod usage;
pub(crate) use bash_approval::BashApprovalState;
pub(crate) use connect_provider::ConnectProviderField;
pub(crate) use connect_provider::ConnectProviderStage;
pub(crate) use connect_provider::OAuthControl;
pub(crate) use login_logout::LoginProviderEntry;
pub(crate) use login_logout::LogoutConfirmationState;
pub(crate) use login_logout::LogoutPickerState;
use picker::clamp_picker_selection;
use picker::keep_picker_selection_visible;
use picker::picker_selection_after_move;
use picker::skill_row_visual_position;
pub(crate) use connect_provider::ConnectProviderState;
pub(crate) use model_picker::ModelPickerState;
pub(crate) use session_picker::PreviewMessage;
pub(crate) use session_picker::SessionPickerFocus;
pub(crate) use session_picker::SessionPickerRow;
pub(crate) use session_picker::SessionPickerState;
pub(crate) use session_picker::SessionPreview;
pub(crate) use session_picker::SessionPreviewPartial;
pub(crate) use session_picker::SessionPreviewState;
pub(crate) use theme_picker::ThemePickerState;
pub(crate) use theme_picker::ThemeSaveState;
use crate::{config::SettingsScope, output::ActivityId, tui::single_line_field::SingleLineField};
pub(crate) use capability_picker::McpModalState;
pub(crate) use capability_picker::McpServerToggleRow;
pub(crate) use capability_picker::SkillToggleRow;
pub(crate) use capability_picker::SkillsModalState;
pub(crate) use capability_picker::SubagentProfileToggleRow;
pub(crate) use capability_picker::SubagentsModalState;
pub(crate) use capability_picker::ToolToggleRow;
pub(crate) use capability_picker::ToolsModalState;
pub(crate) use rewind::RewindModalState;
use std::collections::HashMap;
pub(crate) use system_prompt::SystemPromptCard;
pub(crate) use system_prompt::SystemPromptModalState;
use unicode_segmentation::UnicodeSegmentation;
pub(crate) use usage::UsageModalState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModalKind {
Summary,
ActivityTree,
SessionFiles,
ActivityDetail,
SubagentViewer,
SessionPicker,
Skills,
Mcp,
Tools,
Subagents,
Usage,
SystemPrompt,
Rewind,
ConnectProvider,
BashApproval,
LogoutConfirmation,
LogoutPicker,
ModelPicker,
ThemePicker,
Settings,
}
#[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) header_modals: Vec<ModalKind>,
pub(crate) focused_header_modal: Option<ModalKind>,
pub(crate) header_modals_pinned: bool,
pub(crate) header_modals_opened_by_pin: bool,
pub(crate) activity_detail: Option<ActivityId>,
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) bash_approval: Option<BashApprovalState>,
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) session_picker: Option<SessionPickerState>,
pub(crate) subagent_viewer: Option<SubagentViewerState>,
pub(crate) theme_picker: Option<ThemePickerState>,
pub(crate) settings_editor: Option<crate::tui::settings_editor::SettingsEditor>,
}
use super::MissionControlState;
use crate::tui::transcript;
impl MissionControlState {
pub(crate) fn open_activity_detail(&mut self) -> bool {
let Some(id) = self.selected_activity_id() else {
return false;
};
self.modals.activity_detail = Some(id);
self.activity_follow_tail = false;
self.reset_detail();
true
}
pub(crate) fn top_modal(&self) -> Option<ModalKind> {
if self.modals.tools_modal.is_some() {
return Some(ModalKind::Tools);
}
if self.modals.settings_editor.is_some() {
return Some(ModalKind::Settings);
}
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.subagents_modal.is_some() {
Some(ModalKind::Subagents)
} 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.bash_approval.is_some() {
Some(ModalKind::BashApproval)
} 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 if self.modals.activity_detail.is_some() {
Some(ModalKind::ActivityDetail)
} else if self.header_modals_allowed_on_tab() {
self.modals
.focused_header_modal
.or_else(|| self.modals.header_modals.last().copied())
} 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_switch_variant(&mut self) -> bool {
let Some(picker) = self.modals.theme_picker.as_mut() else {
return false;
};
if picker.loading || matches!(picker.save_state, ThemeSaveState::Pending) {
return false;
}
picker.active_variant = picker.next_variant();
picker.refilter();
self.set_scroll_offset(&self.scroll_views.theme_modal, 0);
true
}
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);
}
}