use tracing::warn;
use crate::app::AppServices;
use crate::domain::agent::{
self, AgentKind, AgentModel, AgentSelection, AgentSelectionMetadata, ReasoningLevel,
};
use crate::domain::input::InputState;
use crate::domain::selection::SelectionState;
use crate::domain::setting::SettingName;
use crate::domain::theme::ColorTheme;
use crate::infra::db::AppRepositories;
pub(crate) async fn load_default_smart_model_setting(
services: &AppServices,
project_id: Option<i64>,
fallback_model: AgentModel,
) -> AgentModel {
let available_agent_kinds = services.available_agent_kinds();
load_default_smart_agent_setting(
services,
project_id,
fallback_selection_for_available_model(fallback_model, &available_agent_kinds),
)
.await
.model()
}
pub(crate) async fn load_default_smart_agent_setting(
services: &AppServices,
project_id: Option<i64>,
fallback_selection: AgentSelection,
) -> AgentSelection {
let available_agent_kinds = services.available_agent_kinds();
load_default_smart_agent_selection_from_repositories(
services.db(),
project_id,
fallback_selection,
&available_agent_kinds,
)
.await
}
pub(crate) async fn load_default_fast_agent_setting(
services: &AppServices,
project_id: Option<i64>,
fallback_selection: AgentSelection,
) -> AgentSelection {
let available_agent_kinds = services.available_agent_kinds();
load_default_fast_agent_selection_from_repositories(
services.db(),
project_id,
fallback_selection,
&available_agent_kinds,
)
.await
}
pub(crate) async fn load_default_fast_agent_selection_from_repositories(
repositories: &AppRepositories,
project_id: Option<i64>,
fallback_selection: AgentSelection,
available_agent_kinds: &[AgentKind],
) -> AgentSelection {
let fallback_selection = resolve_available_selection(fallback_selection, available_agent_kinds);
if let Some(selection) = load_model_selection_setting(
repositories,
project_id,
SettingName::DefaultFastAgent,
SettingName::DefaultFastModel,
fallback_selection,
)
.await
{
return resolve_available_selection(selection, available_agent_kinds);
}
load_default_smart_agent_selection_from_repositories(
repositories,
project_id,
fallback_selection,
available_agent_kinds,
)
.await
}
pub(crate) async fn load_default_smart_agent_selection_from_repositories(
repositories: &AppRepositories,
project_id: Option<i64>,
fallback_selection: AgentSelection,
available_agent_kinds: &[AgentKind],
) -> AgentSelection {
let fallback_selection = resolve_available_selection(fallback_selection, available_agent_kinds);
if let Some(selection) = load_model_selection_setting(
repositories,
project_id,
SettingName::DefaultSmartAgent,
SettingName::DefaultSmartModel,
fallback_selection,
)
.await
{
return resolve_available_selection(selection, available_agent_kinds);
}
fallback_selection
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SettingsSelectorDropdownOption {
pub label: String,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct SettingsSelectorDropdown {
pub options: Vec<SettingsSelectorDropdownOption>,
pub row_index: usize,
pub selected_index: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LaunchConfigurationListEditorMode {
Add,
Browse,
Edit,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LaunchConfigurationListEditorSnapshot {
pub commands: Vec<String>,
pub input: Option<InputState>,
pub mode: LaunchConfigurationListEditorMode,
pub selected_index: usize,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SettingControl {
CommandList,
Selector,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SettingRow {
ReasoningLevel,
DefaultSmartModel,
DefaultFastModel,
DefaultReviewModel,
IncludeCoauthoredByAgentty,
LaunchConfiguration,
Theme,
}
impl SettingRow {
const ALL: [Self; 7] = [
Self::Theme,
Self::ReasoningLevel,
Self::DefaultSmartModel,
Self::DefaultFastModel,
Self::DefaultReviewModel,
Self::IncludeCoauthoredByAgentty,
Self::LaunchConfiguration,
];
const GLOBAL: [Self; 1] = [Self::Theme];
const PROJECT: [Self; 6] = [
Self::ReasoningLevel,
Self::DefaultSmartModel,
Self::DefaultFastModel,
Self::DefaultReviewModel,
Self::IncludeCoauthoredByAgentty,
Self::LaunchConfiguration,
];
const ROW_COUNT: usize = Self::ALL.len();
fn from_index(index: usize) -> Self {
Self::ALL
.get(index)
.copied()
.unwrap_or(Self::ReasoningLevel)
}
fn label(self) -> &'static str {
match self {
Self::ReasoningLevel => "Default Reasoning Level",
Self::DefaultSmartModel => "Default Smart Model",
Self::DefaultFastModel => "Default Fast Model",
Self::DefaultReviewModel => "Default Review Model",
Self::IncludeCoauthoredByAgentty => "Coauthored by Agentty",
Self::LaunchConfiguration => "Launch Configurations",
Self::Theme => "Theme",
}
}
fn control(self) -> SettingControl {
match self {
Self::ReasoningLevel
| Self::DefaultSmartModel
| Self::DefaultFastModel
| Self::DefaultReviewModel
| Self::IncludeCoauthoredByAgentty
| Self::Theme => SettingControl::Selector,
Self::LaunchConfiguration => SettingControl::CommandList,
}
}
fn table_index(self) -> usize {
Self::ALL
.iter()
.position(|row| *row == self)
.unwrap_or_default()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SelectorDropdownState {
row: SettingRow,
selected_index: usize,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct LaunchConfigurationListEditorState {
commands: Vec<String>,
input: InputState,
mode: LaunchConfigurationListEditorMode,
selected_index: usize,
}
impl LaunchConfigurationListEditorState {
fn from_launch_configuration(launch_configuration: &str) -> Self {
Self {
commands: parse_launch_configurations(launch_configuration),
input: InputState::default(),
mode: LaunchConfigurationListEditorMode::Browse,
selected_index: 0,
}
}
fn selected_index(&self) -> usize {
self.selected_index
.min(self.commands.len().saturating_sub(1))
}
fn clamp_selected_index(&mut self) {
self.selected_index = self.selected_index();
}
fn is_input_mode(&self) -> bool {
matches!(
self.mode,
LaunchConfigurationListEditorMode::Add | LaunchConfigurationListEditorMode::Edit
)
}
}
pub struct SettingsManager {
pub default_fast_selection: AgentSelection,
pub default_review_selection: AgentSelection,
pub default_smart_selection: AgentSelection,
pub launch_configuration: String,
pub reasoning_level: ReasoningLevel,
pub table_state: SelectionState,
pub theme: ColorTheme,
available_agent_kinds: Vec<AgentKind>,
include_coauthored_by_agentty: bool,
launch_configuration_list_editor: Option<LaunchConfigurationListEditorState>,
project_id: i64,
selector_dropdown: Option<SelectorDropdownState>,
use_last_used_model_as_default: bool,
}
impl SettingsManager {
pub async fn new(services: &AppServices, project_id: i64) -> Self {
let available_agent_kinds = services.available_agent_kinds();
let default_smart_fallback = fallback_selection_for_available_model(
AgentKind::Antigravity.default_model(),
&available_agent_kinds,
);
let default_smart_agent =
load_default_smart_agent_setting(services, Some(project_id), default_smart_fallback)
.await;
let default_fast_agent =
load_default_fast_agent_setting(services, Some(project_id), default_smart_agent).await;
let default_review_agent = load_model_selection_setting(
services.db(),
Some(project_id),
SettingName::DefaultReviewAgent,
SettingName::DefaultReviewModel,
default_smart_agent,
)
.await
.map_or(default_smart_agent, |selection| {
resolve_available_selection(selection, &available_agent_kinds)
});
let reasoning_level = load_reasoning_level_setting(services, Some(project_id)).await;
let launch_configuration = services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.unwrap_or(None)
.unwrap_or_default();
let include_coauthored_by_agentty = load_project_bool_setting(
services,
Some(project_id),
SettingName::IncludeCoauthoredByAgentty,
false,
)
.await;
let use_last_used_model_as_default = load_project_bool_setting(
services,
Some(project_id),
SettingName::LastUsedModelAsDefault,
false,
)
.await;
let theme = load_theme_setting(services).await;
let mut table_state = SelectionState::default();
table_state.select(Some(0));
Self {
default_fast_selection: default_fast_agent,
default_review_selection: default_review_agent,
default_smart_selection: default_smart_agent,
launch_configuration,
reasoning_level,
table_state,
theme,
available_agent_kinds,
include_coauthored_by_agentty,
launch_configuration_list_editor: None,
project_id,
selector_dropdown: None,
use_last_used_model_as_default,
}
}
pub fn next(&mut self) {
if !self.is_launch_configuration_list_editor_open() && !self.is_selector_dropdown_open() {
let next_index = (self.selected_row_index() + 1) % SettingRow::ROW_COUNT;
self.table_state.select(Some(next_index));
}
}
pub fn previous(&mut self) {
if !self.is_launch_configuration_list_editor_open() && !self.is_selector_dropdown_open() {
let current_index = self.selected_row_index();
let previous_index = if current_index == 0 {
SettingRow::ROW_COUNT - 1
} else {
current_index - 1
};
self.table_state.select(Some(previous_index));
}
}
pub fn handle_enter(&mut self) {
let selected_row = self.selected_row();
match selected_row.control() {
SettingControl::Selector => {
self.open_selector_dropdown(selected_row);
}
SettingControl::CommandList => {
self.open_launch_configuration_list_editor();
}
}
}
#[must_use]
pub fn is_launch_configuration_list_editor_open(&self) -> bool {
self.launch_configuration_list_editor.is_some()
}
#[must_use]
pub fn is_launch_configuration_list_editor_input_active(&self) -> bool {
self.launch_configuration_list_editor
.as_ref()
.is_some_and(LaunchConfigurationListEditorState::is_input_mode)
}
#[must_use]
pub fn is_selector_dropdown_open(&self) -> bool {
self.selector_dropdown.is_some()
}
#[must_use]
pub fn launch_configuration_list_editor(
&self,
) -> Option<LaunchConfigurationListEditorSnapshot> {
let editor = self.launch_configuration_list_editor.as_ref()?;
let input = editor.is_input_mode().then(|| editor.input.clone());
Some(LaunchConfigurationListEditorSnapshot {
commands: editor.commands.clone(),
input,
mode: editor.mode,
selected_index: editor.selected_index(),
})
}
#[must_use]
pub fn selector_dropdown(&self) -> Option<SettingsSelectorDropdown> {
let selector_dropdown = self.selector_dropdown?;
let options = self.selector_options_for_row(selector_dropdown.row);
if options.is_empty() {
return None;
}
let selected_index = selector_dropdown
.selected_index
.min(options.len().saturating_sub(1));
Some(SettingsSelectorDropdown {
options: options
.into_iter()
.map(|option| SettingsSelectorDropdownOption {
label: option.label,
})
.collect(),
row_index: selector_dropdown.row.table_index(),
selected_index,
})
}
pub fn next_selector_dropdown_option(&mut self) {
self.move_selector_dropdown_option(SelectorDropdownDirection::Next);
}
pub fn previous_selector_dropdown_option(&mut self) {
self.move_selector_dropdown_option(SelectorDropdownDirection::Previous);
}
pub fn close_selector_dropdown(&mut self) {
self.selector_dropdown = None;
}
pub async fn select_selector_dropdown_option(&mut self, services: &AppServices) {
let Some(selector_dropdown) = self.selector_dropdown else {
return;
};
let options = self.selector_options_for_row(selector_dropdown.row);
let selected_value = options
.get(
selector_dropdown
.selected_index
.min(options.len().saturating_sub(1)),
)
.map(|option| option.value);
if let Some(selected_value) = selected_value {
self.apply_selector_value(services, selector_dropdown.row, selected_value)
.await;
}
self.close_selector_dropdown();
}
pub fn close_launch_configuration_list_editor(&mut self) {
self.launch_configuration_list_editor = None;
}
pub fn next_launch_configuration_list_editor_item(&mut self) {
self.move_launch_configuration_list_editor_selection(
LaunchConfigurationListDirection::Next,
);
}
pub fn previous_launch_configuration_list_editor_item(&mut self) {
self.move_launch_configuration_list_editor_selection(
LaunchConfigurationListDirection::Previous,
);
}
pub fn start_adding_launch_configuration(&mut self) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
editor.input = InputState::default();
editor.mode = LaunchConfigurationListEditorMode::Add;
}
pub fn start_editing_selected_launch_configuration(&mut self) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if editor.commands.is_empty() {
editor.input = InputState::default();
editor.mode = LaunchConfigurationListEditorMode::Add;
return;
}
let selected_index = editor.selected_index();
let selected_command = editor.commands[selected_index].clone();
editor.selected_index = selected_index;
editor.input = InputState::with_text(selected_command);
editor.mode = LaunchConfigurationListEditorMode::Edit;
}
pub fn cancel_launch_configuration_input(&mut self) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
editor.input = InputState::default();
editor.mode = LaunchConfigurationListEditorMode::Browse;
}
pub fn append_launch_configuration_input_character(&mut self, character: char) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if editor.is_input_mode() {
editor.input.insert_char(character);
}
}
pub fn remove_launch_configuration_input_character(&mut self) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if editor.is_input_mode() {
editor.input.delete_backward();
}
}
pub fn delete_launch_configuration_input_character(&mut self) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if editor.is_input_mode() {
editor.input.delete_forward();
}
}
pub fn move_launch_configuration_input_cursor_left(&mut self) {
self.move_launch_configuration_input_cursor(LaunchConfigurationInputCursorDirection::Left);
}
pub fn move_launch_configuration_input_cursor_right(&mut self) {
self.move_launch_configuration_input_cursor(LaunchConfigurationInputCursorDirection::Right);
}
pub fn move_launch_configuration_input_cursor_home(&mut self) {
self.move_launch_configuration_input_cursor(LaunchConfigurationInputCursorDirection::Home);
}
pub fn move_launch_configuration_input_cursor_end(&mut self) {
self.move_launch_configuration_input_cursor(LaunchConfigurationInputCursorDirection::End);
}
pub async fn confirm_launch_configuration_input(&mut self, services: &AppServices) {
if self.apply_launch_configuration_input() {
self.persist_launch_configuration_setting(services).await;
}
}
pub async fn delete_selected_launch_configuration(&mut self, services: &AppServices) {
if self.delete_selected_launch_configuration_from_editor() {
self.persist_launch_configuration_setting(services).await;
}
}
pub async fn move_selected_launch_configuration_down(&mut self, services: &AppServices) {
if self.reorder_selected_launch_configuration(LaunchConfigurationReorderDirection::Down) {
self.persist_launch_configuration_setting(services).await;
}
}
pub async fn move_selected_launch_configuration_up(&mut self, services: &AppServices) {
if self.reorder_selected_launch_configuration(LaunchConfigurationReorderDirection::Up) {
self.persist_launch_configuration_setting(services).await;
}
}
#[must_use]
pub fn settings_rows(&self) -> Vec<(&'static str, String)> {
SettingRow::ALL
.iter()
.map(|row| (row.label(), self.display_value_for_row(*row)))
.collect()
}
#[must_use]
pub fn global_settings_rows(&self) -> Vec<(&'static str, String)> {
SettingRow::GLOBAL
.iter()
.map(|row| (row.label(), self.display_value_for_row(*row)))
.collect()
}
#[must_use]
pub fn project_settings_rows(&self) -> Vec<(&'static str, String)> {
SettingRow::PROJECT
.iter()
.map(|row| (row.label(), self.display_value_for_row(*row)))
.collect()
}
#[must_use]
pub fn footer_hint(&self) -> &'static str {
if self
.launch_configuration_list_editor
.as_ref()
.is_some_and(LaunchConfigurationListEditorState::is_input_mode)
{
"Launch Configurations: type a command, Enter save, Esc cancel"
} else if self.is_launch_configuration_list_editor_open() {
"Launch Configurations: j/k move, a add, e/Enter edit, d delete, J/K reorder, Esc/q \
close"
} else if self.is_selector_dropdown_open() {
"Selecting setting value: j/k move, Enter select, Esc/q close"
} else {
"Settings: Enter opens selectors or command editor"
}
}
#[must_use]
pub fn launch_configurations(&self) -> Vec<String> {
parse_launch_configurations(self.launch_configuration.as_str())
}
fn selected_row_index(&self) -> usize {
self.table_state
.selected()
.unwrap_or(0)
.min(SettingRow::ROW_COUNT - 1)
}
fn selected_row(&self) -> SettingRow {
SettingRow::from_index(self.selected_row_index())
}
fn open_selector_dropdown(&mut self, row: SettingRow) {
if !matches!(row.control(), SettingControl::Selector) {
return;
}
let options = self.selector_options_for_row(row);
if options.is_empty() {
return;
}
let selected_index = self.current_selector_option_index(row, &options);
self.selector_dropdown = Some(SelectorDropdownState {
row,
selected_index,
});
}
fn move_selector_dropdown_option(&mut self, direction: SelectorDropdownDirection) {
let Some(selector_dropdown) = self.selector_dropdown else {
return;
};
let option_count = self.selector_options_for_row(selector_dropdown.row).len();
if option_count == 0 {
self.close_selector_dropdown();
return;
}
let selected_index = match direction {
SelectorDropdownDirection::Next => {
(selector_dropdown.selected_index + 1) % option_count
}
SelectorDropdownDirection::Previous => {
if selector_dropdown.selected_index == 0 {
option_count - 1
} else {
selector_dropdown.selected_index - 1
}
}
};
self.selector_dropdown = Some(SelectorDropdownState {
row: selector_dropdown.row,
selected_index,
});
}
fn selector_options_for_row(&self, row: SettingRow) -> Vec<SettingSelectorOption> {
match row {
SettingRow::ReasoningLevel => ReasoningLevel::ALL
.iter()
.copied()
.map(|reasoning_level| SettingSelectorOption {
label: reasoning_level.codex().to_string(),
value: SettingSelectorValue::ReasoningLevel(reasoning_level),
})
.collect(),
SettingRow::DefaultSmartModel => self.default_smart_model_selector_options(),
SettingRow::DefaultFastModel | SettingRow::DefaultReviewModel => {
self.explicit_model_selector_options()
}
SettingRow::IncludeCoauthoredByAgentty => vec![
SettingSelectorOption {
label: bool_setting_display(false),
value: SettingSelectorValue::Bool(false),
},
SettingSelectorOption {
label: bool_setting_display(true),
value: SettingSelectorValue::Bool(true),
},
],
SettingRow::LaunchConfiguration => Vec::new(),
SettingRow::Theme => ColorTheme::ALL
.iter()
.copied()
.map(|theme| SettingSelectorOption {
label: theme.label().to_string(),
value: SettingSelectorValue::Theme(theme),
})
.collect(),
}
}
fn default_smart_model_selector_options(&self) -> Vec<SettingSelectorOption> {
let mut options = self.explicit_model_selector_options();
options.push(SettingSelectorOption {
label: "Last used model as default".to_string(),
value: SettingSelectorValue::LastUsedModel,
});
options
}
fn explicit_model_selector_options(&self) -> Vec<SettingSelectorOption> {
self.selectable_model_options()
.into_iter()
.map(|option| {
let selection = option.selection();
SettingSelectorOption {
label: display_model_selector_value(selection),
value: SettingSelectorValue::ModelSelection(selection),
}
})
.collect()
}
fn current_selector_option_index(
&self,
row: SettingRow,
options: &[SettingSelectorOption],
) -> usize {
options
.iter()
.position(|option| option.is_current_for(self, row))
.unwrap_or_default()
}
async fn apply_selector_value(
&mut self,
services: &AppServices,
row: SettingRow,
value: SettingSelectorValue,
) {
match (row, value) {
(SettingRow::ReasoningLevel, SettingSelectorValue::ReasoningLevel(reasoning_level)) => {
self.reasoning_level = reasoning_level;
self.persist_reasoning_level_setting(services).await;
}
(SettingRow::DefaultSmartModel, SettingSelectorValue::LastUsedModel) => {
self.use_last_used_model_as_default = true;
self.persist_default_smart_model_settings(services).await;
}
(SettingRow::DefaultSmartModel, SettingSelectorValue::ModelSelection(selection)) => {
self.default_smart_selection = selection;
self.use_last_used_model_as_default = false;
self.persist_default_smart_model_settings(services).await;
}
(SettingRow::DefaultFastModel, SettingSelectorValue::ModelSelection(selection)) => {
self.default_fast_selection = selection;
self.persist_default_fast_model_setting(services).await;
}
(SettingRow::DefaultReviewModel, SettingSelectorValue::ModelSelection(selection)) => {
self.default_review_selection = selection;
self.persist_default_review_model_setting(services).await;
}
(SettingRow::IncludeCoauthoredByAgentty, SettingSelectorValue::Bool(is_enabled)) => {
self.include_coauthored_by_agentty = is_enabled;
self.persist_include_coauthored_by_agentty_setting(services)
.await;
}
(SettingRow::Theme, SettingSelectorValue::Theme(theme)) => {
self.theme = theme;
self.persist_theme_setting(services).await;
}
_ => {}
}
}
fn open_launch_configuration_list_editor(&mut self) {
self.launch_configuration_list_editor = Some(
LaunchConfigurationListEditorState::from_launch_configuration(
self.launch_configuration.as_str(),
),
);
}
fn move_launch_configuration_list_editor_selection(
&mut self,
direction: LaunchConfigurationListDirection,
) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if editor.commands.is_empty() || editor.is_input_mode() {
return;
}
let command_count = editor.commands.len();
editor.selected_index = match direction {
LaunchConfigurationListDirection::Next => (editor.selected_index + 1) % command_count,
LaunchConfigurationListDirection::Previous => {
if editor.selected_index == 0 {
command_count - 1
} else {
editor.selected_index - 1
}
}
};
}
fn move_launch_configuration_input_cursor(
&mut self,
direction: LaunchConfigurationInputCursorDirection,
) {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return;
};
if !editor.is_input_mode() {
return;
}
match direction {
LaunchConfigurationInputCursorDirection::End => editor.input.move_end(),
LaunchConfigurationInputCursorDirection::Home => editor.input.move_home(),
LaunchConfigurationInputCursorDirection::Left => editor.input.move_left(),
LaunchConfigurationInputCursorDirection::Right => editor.input.move_right(),
}
}
fn apply_launch_configuration_input(&mut self) -> bool {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return false;
};
if !editor.is_input_mode() {
return false;
}
let command = editor.input.text().trim().to_string();
match editor.mode {
LaunchConfigurationListEditorMode::Add => {
if !command.is_empty() {
editor.commands.push(command);
editor.selected_index = editor.commands.len().saturating_sub(1);
}
}
LaunchConfigurationListEditorMode::Edit => {
if editor.commands.is_empty() {
if !command.is_empty() {
editor.commands.push(command);
editor.selected_index = 0;
}
} else {
let selected_index = editor.selected_index();
if command.is_empty() {
editor.commands.remove(selected_index);
editor.clamp_selected_index();
} else {
editor.commands[selected_index] = command;
editor.selected_index = selected_index;
}
}
}
LaunchConfigurationListEditorMode::Browse => {}
}
editor.input = InputState::default();
editor.mode = LaunchConfigurationListEditorMode::Browse;
self.sync_launch_configuration_from_editor();
true
}
fn delete_selected_launch_configuration_from_editor(&mut self) -> bool {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return false;
};
if editor.commands.is_empty() || editor.is_input_mode() {
return false;
}
let selected_index = editor.selected_index();
editor.commands.remove(selected_index);
editor.clamp_selected_index();
self.sync_launch_configuration_from_editor();
true
}
fn reorder_selected_launch_configuration(
&mut self,
direction: LaunchConfigurationReorderDirection,
) -> bool {
let Some(editor) = &mut self.launch_configuration_list_editor else {
return false;
};
if editor.commands.len() < 2 || editor.is_input_mode() {
return false;
}
let selected_index = editor.selected_index();
let next_index = match direction {
LaunchConfigurationReorderDirection::Down => {
if selected_index + 1 >= editor.commands.len() {
return false;
}
selected_index + 1
}
LaunchConfigurationReorderDirection::Up => {
if selected_index == 0 {
return false;
}
selected_index - 1
}
};
editor.commands.swap(selected_index, next_index);
editor.selected_index = next_index;
self.sync_launch_configuration_from_editor();
true
}
fn sync_launch_configuration_from_editor(&mut self) {
if let Some(editor) = &mut self.launch_configuration_list_editor {
editor.commands = normalize_launch_configurations(&editor.commands);
editor.clamp_selected_index();
self.launch_configuration = join_launch_configurations(&editor.commands);
}
}
fn display_value_for_row(&self, row: SettingRow) -> String {
match row {
SettingRow::ReasoningLevel => self.reasoning_level.codex().to_string(),
SettingRow::DefaultSmartModel => {
if self.use_last_used_model_as_default {
"Last used model as default".to_string()
} else {
display_model_selector_value(self.default_smart_selection)
}
}
SettingRow::DefaultFastModel => {
display_model_selector_value(self.default_fast_selection)
}
SettingRow::DefaultReviewModel => {
display_model_selector_value(self.default_review_selection)
}
SettingRow::IncludeCoauthoredByAgentty => {
bool_setting_display(self.include_coauthored_by_agentty)
}
SettingRow::LaunchConfiguration => {
display_launch_configuration_summary(&self.launch_configuration)
}
SettingRow::Theme => self.theme.label().to_string(),
}
}
async fn persist_launch_configuration_setting(&self, services: &AppServices) {
let _ = services
.db()
.settings()
.upsert_project_setting(
self.project_id,
SettingName::LaunchConfiguration,
&self.launch_configuration,
)
.await;
}
fn selectable_model_options(&self) -> Vec<ModelSelectorOption> {
selectable_model_options(&self.available_agent_kinds)
}
async fn persist_default_smart_model_settings(&self, services: &AppServices) {
let last_used_model_as_default_value = self.use_last_used_model_as_default.to_string();
if let Err(error) = services
.db()
.settings()
.upsert_project_settings(
self.project_id,
vec![
(
SettingName::DefaultSmartModel,
self.default_smart_selection.model().as_str().to_string(),
),
(
SettingName::DefaultSmartAgent,
self.default_smart_selection.kind().name().to_string(),
),
(
SettingName::LastUsedModelAsDefault,
last_used_model_as_default_value,
),
],
)
.await
{
warn!(
project_id = self.project_id,
error = %error,
"failed to persist default smart model settings"
);
}
}
async fn persist_reasoning_level_setting(&self, services: &AppServices) {
let _ = services
.db()
.settings()
.set_project_reasoning_level(self.project_id, self.reasoning_level)
.await;
}
async fn persist_default_fast_model_setting(&self, services: &AppServices) {
if let Err(error) = services
.db()
.settings()
.upsert_project_settings(
self.project_id,
vec![
(
SettingName::DefaultFastModel,
self.default_fast_selection.model().as_str().to_string(),
),
(
SettingName::DefaultFastAgent,
self.default_fast_selection.kind().name().to_string(),
),
],
)
.await
{
warn!(
project_id = self.project_id,
error = %error,
"failed to persist default fast model settings"
);
}
}
async fn persist_default_review_model_setting(&self, services: &AppServices) {
if let Err(error) = services
.db()
.settings()
.upsert_project_settings(
self.project_id,
vec![
(
SettingName::DefaultReviewModel,
self.default_review_selection.model().as_str().to_string(),
),
(
SettingName::DefaultReviewAgent,
self.default_review_selection.kind().name().to_string(),
),
],
)
.await
{
warn!(
project_id = self.project_id,
error = %error,
"failed to persist default review model settings"
);
}
}
async fn persist_include_coauthored_by_agentty_setting(&self, services: &AppServices) {
let include_coauthored_by_agentty = self.include_coauthored_by_agentty.to_string();
let _ = services
.db()
.settings()
.upsert_project_setting(
self.project_id,
SettingName::IncludeCoauthoredByAgentty,
&include_coauthored_by_agentty,
)
.await;
}
async fn persist_theme_setting(&self, services: &AppServices) {
let _ = services
.db()
.settings()
.upsert_setting(SettingName::Theme, self.theme.as_str())
.await;
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LaunchConfigurationListDirection {
Next,
Previous,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LaunchConfigurationInputCursorDirection {
End,
Home,
Left,
Right,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum LaunchConfigurationReorderDirection {
Down,
Up,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SelectorDropdownDirection {
Next,
Previous,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SettingSelectorOption {
label: String,
value: SettingSelectorValue,
}
impl SettingSelectorOption {
fn is_current_for(&self, manager: &SettingsManager, row: SettingRow) -> bool {
match (row, self.value) {
(SettingRow::ReasoningLevel, SettingSelectorValue::ReasoningLevel(reasoning_level)) => {
manager.reasoning_level == reasoning_level
}
(SettingRow::DefaultSmartModel, SettingSelectorValue::LastUsedModel) => {
manager.use_last_used_model_as_default
}
(SettingRow::DefaultSmartModel, SettingSelectorValue::ModelSelection(selection)) => {
!manager.use_last_used_model_as_default
&& manager.default_smart_selection == selection
}
(SettingRow::DefaultFastModel, SettingSelectorValue::ModelSelection(selection)) => {
manager.default_fast_selection == selection
}
(SettingRow::DefaultReviewModel, SettingSelectorValue::ModelSelection(selection)) => {
manager.default_review_selection == selection
}
(SettingRow::IncludeCoauthoredByAgentty, SettingSelectorValue::Bool(is_enabled)) => {
manager.include_coauthored_by_agentty == is_enabled
}
(SettingRow::Theme, SettingSelectorValue::Theme(theme)) => manager.theme == theme,
_ => false,
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SettingSelectorValue {
Bool(bool),
LastUsedModel,
ModelSelection(AgentSelection),
ReasoningLevel(ReasoningLevel),
Theme(ColorTheme),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct ModelSelectorOption {
agent_kind: AgentKind,
model: AgentModel,
}
impl ModelSelectorOption {
fn selection(self) -> AgentSelection {
AgentSelection::new(self.agent_kind, self.model)
}
}
fn parse_launch_configurations(launch_configuration_setting: &str) -> Vec<String> {
launch_configuration_setting
.lines()
.map(str::trim)
.filter(|command| !command.is_empty())
.map(std::string::ToString::to_string)
.collect()
}
fn normalize_launch_configurations(commands: &[String]) -> Vec<String> {
commands
.iter()
.map(|command| command.trim())
.filter(|command| !command.is_empty())
.map(std::string::ToString::to_string)
.collect()
}
fn join_launch_configurations(commands: &[String]) -> String {
normalize_launch_configurations(commands).join("\n")
}
async fn load_project_bool_setting(
services: &AppServices,
project_id: Option<i64>,
setting_name: SettingName,
default_value: bool,
) -> bool {
let Some(project_id) = project_id else {
return default_value;
};
services
.db()
.settings()
.get_project_setting(project_id, setting_name)
.await
.unwrap_or(None)
.and_then(|setting_value| setting_value.parse::<bool>().ok())
.unwrap_or(default_value)
}
fn bool_setting_display(setting_value: bool) -> String {
if setting_value {
"Enabled".to_string()
} else {
"Disabled".to_string()
}
}
fn display_model_selector_value(selection: AgentSelection) -> String {
format!("{}/{}", selection.kind(), selection.model().as_str())
}
fn display_launch_configuration_summary(launch_configuration_setting: &str) -> String {
let commands = parse_launch_configurations(launch_configuration_setting);
let Some(first_command) = commands.first() else {
return "(none)".to_string();
};
if commands.len() == 1 {
return first_command.clone();
}
format!("{} (+{} more)", first_command, commands.len() - 1)
}
fn selectable_model_options(available_agent_kinds: &[AgentKind]) -> Vec<ModelSelectorOption> {
available_agent_kinds
.iter()
.copied()
.flat_map(|agent_kind| {
agent_kind
.models()
.iter()
.copied()
.map(move |model| ModelSelectorOption { agent_kind, model })
})
.collect()
}
fn resolve_available_model(
model: AgentModel,
available_agent_kinds: &[AgentKind],
fallback_model: AgentModel,
) -> AgentModel {
agent::resolve_model_for_available_agent_kinds(model, available_agent_kinds, fallback_model)
}
fn resolve_available_selection(
selection: AgentSelection,
available_agent_kinds: &[AgentKind],
) -> AgentSelection {
if available_agent_kinds.contains(&selection.kind())
&& selection.kind().supports_model(selection.model())
{
return selection;
}
let model =
resolve_available_model(selection.model(), available_agent_kinds, selection.model());
let fallback_agent_kind = available_agent_kinds
.first()
.copied()
.unwrap_or(selection.kind());
let agent_kind =
agent::resolve_agent_kind_for_model(model, available_agent_kinds, fallback_agent_kind);
AgentSelection::new(agent_kind, model)
}
async fn load_model_setting(
repositories: &AppRepositories,
project_id: Option<i64>,
setting_name: SettingName,
) -> Option<AgentModel> {
let project_id = project_id?;
repositories
.settings()
.get_project_setting(project_id, setting_name)
.await
.unwrap_or(None)
.and_then(|setting_value| AgentModel::parse_persisted(&setting_value).ok())
}
async fn load_model_selection_setting(
repositories: &AppRepositories,
project_id: Option<i64>,
agent_setting_name: SettingName,
model_setting_name: SettingName,
fallback_selection: AgentSelection,
) -> Option<AgentSelection> {
let model = load_model_setting(repositories, project_id, model_setting_name).await?;
let agent_kind = load_agent_setting(repositories, project_id, agent_setting_name)
.await
.filter(|agent_kind| agent_kind.supports_model(model))
.unwrap_or_else(|| fallback_agent_kind_for_model(model, fallback_selection.kind()));
Some(AgentSelection::new(agent_kind, model))
}
async fn load_agent_setting(
repositories: &AppRepositories,
project_id: Option<i64>,
setting_name: SettingName,
) -> Option<AgentKind> {
let project_id = project_id?;
repositories
.settings()
.get_project_setting(project_id, setting_name)
.await
.unwrap_or(None)
.and_then(|setting_value| setting_value.parse::<AgentKind>().ok())
}
fn fallback_agent_kind_for_model(model: AgentModel, fallback_agent_kind: AgentKind) -> AgentKind {
if fallback_agent_kind.supports_model(model) {
return fallback_agent_kind;
}
AgentKind::ALL
.iter()
.copied()
.find(|agent_kind| agent_kind.supports_model(model))
.unwrap_or(fallback_agent_kind)
}
fn fallback_selection_for_available_model(
model: AgentModel,
available_agent_kinds: &[AgentKind],
) -> AgentSelection {
let fallback_agent_kind = fallback_agent_kind_for_model(model, AgentKind::Antigravity);
let agent_kind =
agent::resolve_agent_kind_for_model(model, available_agent_kinds, fallback_agent_kind);
AgentSelection::new(agent_kind, model)
}
async fn load_reasoning_level_setting(
services: &AppServices,
project_id: Option<i64>,
) -> ReasoningLevel {
let Some(project_id) = project_id else {
return ReasoningLevel::default();
};
services
.db()
.settings()
.load_project_reasoning_level(project_id)
.await
.unwrap_or_default()
}
async fn load_theme_setting(services: &AppServices) -> ColorTheme {
services
.db()
.settings()
.get_setting(SettingName::Theme)
.await
.unwrap_or(None)
.and_then(|setting_value| ColorTheme::parse_persisted(&setting_value))
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use ag_agent::MockAppServerClient;
use ag_forge as forge;
use ag_git as git;
use tokio::sync::mpsc;
use super::*;
use crate::db::AppRepositories;
use crate::domain::selection::SelectionState;
use crate::infra::fs;
async fn test_services() -> (AppServices, i64) {
let database = AppRepositories::in_memory().await;
let project_id = database
.projects()
.upsert_project("/tmp/project", Some("main".to_string()))
.await
.expect("failed to create project");
let (event_tx, _event_rx) = mpsc::unbounded_channel();
let services = AppServices::new_with_agent_clis(
PathBuf::from("/tmp/agentty-settings-tests"),
Arc::new(crate::infra::clock::RealClock),
event_tx,
crate::app::service::AppServiceDeps {
app_server_client_override: Some(Arc::new(MockAppServerClient::new())),
available_agent_kinds: AgentKind::ALL.to_vec(),
clipboard_image_client_override: None,
fs_client: Arc::new(fs::MockFsClient::new()),
git_client: Arc::new(git::MockGitClient::new()),
repositories: database.clone(),
review_request_client: Arc::new(forge::MockReviewRequestClient::new()),
},
crate::domain::agent::AgentCliInfo::from_kinds(AgentKind::ALL),
);
(services, project_id)
}
fn select_row(manager: &mut SettingsManager, row_index: usize) {
manager.table_state.select(Some(row_index));
}
fn new_settings_manager() -> SettingsManager {
let mut table_state = SelectionState::default();
table_state.select(Some(0));
SettingsManager {
default_fast_selection: AgentSelection::new(
AgentKind::Antigravity,
AgentKind::Antigravity.default_model(),
),
default_review_selection: AgentSelection::new(
AgentKind::Antigravity,
AgentKind::Antigravity.default_model(),
),
default_smart_selection: AgentSelection::new(
AgentKind::Antigravity,
AgentKind::Antigravity.default_model(),
),
launch_configuration: String::new(),
reasoning_level: ReasoningLevel::High,
table_state,
theme: ColorTheme::Current,
available_agent_kinds: AgentKind::ALL.to_vec(),
include_coauthored_by_agentty: false,
launch_configuration_list_editor: None,
project_id: 1,
selector_dropdown: None,
use_last_used_model_as_default: false,
}
}
#[test]
fn setting_name_as_str_returns_default_fast_model() {
let setting_name = SettingName::DefaultFastModel.as_str();
assert_eq!(setting_name, "DefaultFastModel");
}
#[test]
fn setting_name_as_str_returns_default_fast_agent() {
let setting_name = SettingName::DefaultFastAgent.as_str();
assert_eq!(setting_name, "DefaultFastAgent");
}
#[test]
fn setting_name_as_str_returns_reasoning_level() {
let setting_name = SettingName::ReasoningLevel.as_str();
assert_eq!(setting_name, "ReasoningLevel");
}
#[test]
fn setting_name_as_str_returns_default_smart_model() {
let setting_name = SettingName::DefaultSmartModel.as_str();
assert_eq!(setting_name, "DefaultSmartModel");
}
#[test]
fn setting_name_as_str_returns_default_smart_agent() {
let setting_name = SettingName::DefaultSmartAgent.as_str();
assert_eq!(setting_name, "DefaultSmartAgent");
}
#[test]
fn setting_name_as_str_returns_default_review_model() {
let setting_name = SettingName::DefaultReviewModel.as_str();
assert_eq!(setting_name, "DefaultReviewModel");
}
#[test]
fn setting_name_as_str_returns_default_review_agent() {
let setting_name = SettingName::DefaultReviewAgent.as_str();
assert_eq!(setting_name, "DefaultReviewAgent");
}
#[test]
fn setting_name_as_str_returns_include_coauthored_by_agentty() {
let setting_name = SettingName::IncludeCoauthoredByAgentty.as_str();
assert_eq!(setting_name, "IncludeCoauthoredByAgentty");
}
#[test]
fn setting_name_as_str_returns_launch_configuration() {
let setting_name = SettingName::LaunchConfiguration.as_str();
assert_eq!(setting_name, "LaunchConfiguration");
}
#[test]
fn setting_name_as_str_returns_last_used_model_as_default() {
let setting_name = SettingName::LastUsedModelAsDefault.as_str();
assert_eq!(setting_name, "LastUsedModelAsDefault");
}
#[test]
fn setting_name_as_str_returns_theme() {
let setting_name = SettingName::Theme.as_str();
assert_eq!(setting_name, "Theme");
}
#[tokio::test]
async fn load_default_smart_model_setting_prefers_project_override() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gpt55.as_str(),
)
.await
.expect("failed to persist smart model");
let loaded_model = load_default_smart_model_setting(
&services,
Some(project_id),
AgentModel::ClaudeHaiku4520251001,
)
.await;
assert_eq!(loaded_model, AgentModel::Gpt55);
}
#[tokio::test]
async fn load_default_smart_agent_setting_prefers_persisted_agent() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(project_id, SettingName::DefaultSmartAgent, "gemini")
.await
.expect("failed to persist smart agent");
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist smart model");
let loaded_selection = load_default_smart_agent_setting(
&services,
Some(project_id),
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini35Flash),
)
.await;
assert_eq!(
loaded_selection,
AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini31ProPreview)
);
}
#[tokio::test]
async fn load_default_smart_model_setting_falls_back_to_default() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
"not-a-valid-model",
)
.await
.expect("failed to persist invalid smart model");
let fallback_loaded_model = load_default_smart_model_setting(
&services,
Some(project_id),
AgentModel::ClaudeHaiku4520251001,
)
.await;
assert_eq!(fallback_loaded_model, AgentModel::ClaudeHaiku4520251001);
}
#[tokio::test]
async fn load_default_fast_agent_setting_migrates_retired_claude_opus_46_setting() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
"claude-opus-4-6",
)
.await
.expect("failed to persist smart model");
let fallback_fast_selection = load_default_fast_agent_setting(
&services,
Some(project_id),
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt53CodexSpark),
)
.await;
assert_eq!(
fallback_fast_selection,
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48)
);
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultFastModel,
AgentModel::Gpt55.as_str(),
)
.await
.expect("failed to persist fast model");
let explicit_fast_selection = load_default_fast_agent_setting(
&services,
Some(project_id),
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt53CodexSpark),
)
.await;
assert_eq!(
explicit_fast_selection,
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55)
);
}
#[tokio::test]
async fn settings_manager_new_loads_project_scoped_values() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gpt55.as_str(),
)
.await
.expect("failed to persist project smart model");
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultFastModel,
AgentModel::Gpt53CodexSpark.as_str(),
)
.await
.expect("failed to persist project fast model");
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultReviewModel,
"claude-opus-4-6",
)
.await
.expect("failed to persist review model");
services
.db()
.settings()
.upsert_project_setting(project_id, SettingName::IncludeCoauthoredByAgentty, "false")
.await
.expect("failed to persist coauthor setting");
services
.db()
.settings()
.upsert_project_setting(project_id, SettingName::LaunchConfiguration, "nvim .")
.await
.expect("failed to persist launch configuration");
services
.db()
.settings()
.set_project_reasoning_level(project_id, ReasoningLevel::Low)
.await
.expect("failed to persist reasoning level");
services
.db()
.settings()
.upsert_project_setting(project_id, SettingName::LastUsedModelAsDefault, "true")
.await
.expect("failed to persist last-used-model flag");
services
.db()
.settings()
.upsert_setting(SettingName::Theme, ColorTheme::Hacker.as_str())
.await
.expect("failed to persist theme setting");
let manager = SettingsManager::new(&services, project_id).await;
assert_eq!(
manager.default_smart_selection,
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55)
);
assert_eq!(
manager.default_fast_selection,
AgentSelection::new(AgentKind::Codex, AgentModel::Gpt53CodexSpark)
);
assert_eq!(
manager.default_review_selection,
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48)
);
assert_eq!(manager.launch_configuration, "nvim .");
assert_eq!(manager.reasoning_level, ReasoningLevel::Low);
assert_eq!(manager.theme, ColorTheme::Hacker);
assert!(!manager.include_coauthored_by_agentty);
assert!(manager.use_last_used_model_as_default);
}
#[tokio::test]
async fn settings_manager_new_defaults_invalid_last_used_model_flag_to_false() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::LastUsedModelAsDefault,
"invalid-bool",
)
.await
.expect("failed to persist invalid flag");
let manager = SettingsManager::new(&services, project_id).await;
assert!(!manager.use_last_used_model_as_default);
}
#[tokio::test]
async fn settings_manager_new_defaults_invalid_coauthor_flag_to_false() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::IncludeCoauthoredByAgentty,
"invalid-bool",
)
.await
.expect("failed to persist invalid coauthor flag");
let manager = SettingsManager::new(&services, project_id).await;
assert!(!manager.include_coauthored_by_agentty);
}
#[tokio::test]
async fn settings_manager_new_defaults_invalid_theme_to_current() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_setting(SettingName::Theme, "invalid-theme")
.await
.expect("failed to persist invalid theme");
let manager = SettingsManager::new(&services, project_id).await;
assert_eq!(manager.theme, ColorTheme::Current);
}
#[tokio::test]
async fn settings_manager_new_loads_persisted_dark_horizon_theme() {
let (services, project_id) = test_services().await;
services
.db()
.settings()
.upsert_setting(SettingName::Theme, ColorTheme::DarkHorizon.as_str())
.await
.expect("failed to persist theme setting");
let manager = SettingsManager::new(&services, project_id).await;
assert_eq!(manager.theme, ColorTheme::DarkHorizon);
}
#[test]
fn next_moves_selection_to_default_reasoning_level_row() {
let mut manager = new_settings_manager();
manager.next();
assert_eq!(manager.table_state.selected(), Some(1));
}
#[test]
fn previous_wraps_to_launch_configurations_row_from_theme_row() {
let mut manager = new_settings_manager();
manager.previous();
assert_eq!(manager.table_state.selected(), Some(6));
}
#[test]
fn is_launch_configuration_list_editor_open_returns_false_by_default() {
let manager = new_settings_manager();
let is_open = manager.is_launch_configuration_list_editor_open();
assert!(!is_open);
}
#[test]
fn settings_rows_include_reasoning_model_coauthor_and_launch_configuration_options() {
let manager = new_settings_manager();
let rows = manager.settings_rows();
assert_eq!(rows.len(), 7);
assert_eq!(rows[0].0, "Theme");
assert_eq!(rows[1].0, "Default Reasoning Level");
assert_eq!(rows[2].0, "Default Smart Model");
assert_eq!(rows[3].0, "Default Fast Model");
assert_eq!(rows[4].0, "Default Review Model");
assert_eq!(rows[5].0, "Coauthored by Agentty");
assert_eq!(rows[6].0, "Launch Configurations");
}
#[test]
fn settings_rows_split_theme_into_global_and_project_sections() {
let manager = new_settings_manager();
let global_rows = manager.global_settings_rows();
let project_rows = manager.project_settings_rows();
assert_eq!(global_rows.len(), 1);
assert_eq!(global_rows[0].0, "Theme");
assert_eq!(project_rows.len(), 6);
assert_eq!(project_rows[0].0, "Default Reasoning Level");
assert_eq!(project_rows[1].0, "Default Smart Model");
assert_eq!(project_rows[2].0, "Default Fast Model");
assert_eq!(project_rows[3].0, "Default Review Model");
assert_eq!(project_rows[4].0, "Coauthored by Agentty");
assert_eq!(project_rows[5].0, "Launch Configurations");
}
#[test]
fn footer_hint_returns_launch_configuration_input_hint_when_input_is_active() {
let mut manager = new_settings_manager();
manager.launch_configuration_list_editor = Some(LaunchConfigurationListEditorState {
commands: Vec::new(),
input: InputState::default(),
mode: LaunchConfigurationListEditorMode::Add,
selected_index: 0,
});
let footer_hint = manager.footer_hint();
assert_eq!(
footer_hint,
"Launch Configurations: type a command, Enter save, Esc cancel"
);
}
#[test]
fn footer_hint_returns_selector_dropdown_hint_when_dropdown_is_open() {
let mut manager = new_settings_manager();
manager.selector_dropdown = Some(SelectorDropdownState {
row: SettingRow::Theme,
selected_index: 0,
});
let footer_hint = manager.footer_hint();
assert_eq!(
footer_hint,
"Selecting setting value: j/k move, Enter select, Esc/q close"
);
}
#[test]
fn launch_configurations_returns_single_trimmed_command() {
let mut manager = new_settings_manager();
manager.launch_configuration = " cargo test ".to_string();
let launch_configurations = manager.launch_configurations();
assert_eq!(launch_configurations, vec!["cargo test".to_string()]);
}
#[test]
fn launch_configurations_splits_newline_entries() {
let mut manager = new_settings_manager();
manager.launch_configuration = " cargo test \n npm run dev \n".to_string();
let launch_configurations = manager.launch_configurations();
assert_eq!(
launch_configurations,
vec!["cargo test".to_string(), "npm run dev".to_string()]
);
}
#[test]
fn launch_configurations_does_not_split_double_pipe_entries() {
let mut manager = new_settings_manager();
manager.launch_configuration = "cargo test || npm run dev".to_string();
let launch_configurations = manager.launch_configurations();
assert_eq!(
launch_configurations,
vec!["cargo test || npm run dev".to_string()]
);
}
#[test]
fn settings_rows_show_empty_placeholder_for_launch_configuration() {
let manager = new_settings_manager();
let rows = manager.settings_rows();
assert_eq!(rows[6].1, "(none)");
}
#[test]
fn settings_rows_show_single_launch_configuration_summary() {
let mut manager = new_settings_manager();
manager.launch_configuration = "http://localhost:5173".to_string();
let rows = manager.settings_rows();
assert_eq!(rows[6].1, "http://localhost:5173");
}
#[test]
fn settings_rows_show_multiple_launch_configuration_summary() {
let mut manager = new_settings_manager();
manager.launch_configuration = "cargo test\nnpm run dev\nlazygit".to_string();
let rows = manager.settings_rows();
assert_eq!(rows[6].1, "cargo test (+2 more)");
}
#[test]
fn settings_rows_show_last_used_model_as_default_value_when_enabled() {
let mut manager = new_settings_manager();
manager.use_last_used_model_as_default = true;
let rows = manager.settings_rows();
assert_eq!(rows[2].1, "Last used model as default");
}
#[test]
fn settings_rows_show_default_smart_model_with_agent_prefix() {
let mut manager = new_settings_manager();
manager.default_smart_selection =
AgentSelection::new(AgentKind::Antigravity, AgentModel::Gemini31ProPreview);
let rows = manager.settings_rows();
assert_eq!(rows[2].1, "antigravity/gemini-3.1-pro-preview");
}
#[test]
fn settings_rows_show_default_smart_model_with_real_gemini_agent() {
let mut manager = new_settings_manager();
manager.available_agent_kinds = vec![AgentKind::Gemini];
manager.default_smart_selection =
AgentSelection::new(AgentKind::Gemini, AgentModel::Gemini31ProPreview);
let rows = manager.settings_rows();
assert_eq!(rows[2].1, "gemini/gemini-3.1-pro-preview");
}
#[test]
fn settings_rows_show_default_fast_model_value() {
let mut manager = new_settings_manager();
manager.default_fast_selection = AgentSelection::new(AgentKind::Codex, AgentModel::Gpt55);
let rows = manager.settings_rows();
assert_eq!(rows[3].1, "codex/gpt-5.5");
}
#[test]
fn settings_rows_show_default_review_model_value() {
let mut manager = new_settings_manager();
manager.default_review_selection =
AgentSelection::new(AgentKind::Claude, AgentModel::ClaudeOpus48);
let rows = manager.settings_rows();
assert_eq!(rows[4].1, "claude/claude-opus-4-8");
}
#[test]
fn settings_rows_show_coauthored_by_agentty_value() {
let mut manager = new_settings_manager();
manager.include_coauthored_by_agentty = false;
let rows = manager.settings_rows();
assert_eq!(rows[5].1, "Disabled");
}
#[test]
fn settings_rows_show_reasoning_level_value() {
let mut manager = new_settings_manager();
manager.reasoning_level = ReasoningLevel::Max;
let rows = manager.settings_rows();
assert_eq!(rows[1].1, "max");
}
#[test]
fn settings_rows_show_theme_value() {
let mut manager = new_settings_manager();
manager.theme = ColorTheme::Hacker;
let rows = manager.settings_rows();
assert_eq!(rows[0].1, "Agentty Green");
}
#[test]
fn handle_enter_opens_launch_configuration_list_editor() {
let mut manager = new_settings_manager();
manager.launch_configuration = "nvim .".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
let editor = manager
.launch_configuration_list_editor()
.expect("expected launch-configuration list editor");
assert_eq!(editor.commands, vec!["nvim .".to_string()]);
assert_eq!(editor.mode, LaunchConfigurationListEditorMode::Browse);
}
#[test]
fn next_and_previous_do_not_move_selection_while_launch_configuration_editor_is_open() {
let mut manager = new_settings_manager();
select_row(&mut manager, 6);
manager.handle_enter();
manager.next();
manager.previous();
assert_eq!(manager.table_state.selected(), Some(6));
assert!(manager.is_launch_configuration_list_editor_open());
}
#[test]
fn next_and_previous_do_not_move_selection_while_selector_dropdown_is_open() {
let mut manager = new_settings_manager();
select_row(&mut manager, 0);
manager.handle_enter();
manager.next();
manager.previous();
assert_eq!(manager.table_state.selected(), Some(0));
assert!(manager.is_selector_dropdown_open());
}
#[test]
fn cancel_launch_configuration_input_returns_to_browse_without_changing_value() {
let mut manager = new_settings_manager();
manager.launch_configuration = "old command".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
manager.start_adding_launch_configuration();
manager.append_launch_configuration_input_character('n');
manager.cancel_launch_configuration_input();
let editor = manager
.launch_configuration_list_editor()
.expect("expected launch-configuration list editor");
assert_eq!(manager.launch_configuration, "old command");
assert_eq!(editor.mode, LaunchConfigurationListEditorMode::Browse);
assert!(editor.input.is_none());
}
#[tokio::test]
async fn confirm_launch_configuration_input_adds_trimmed_command_and_persists_value() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
select_row(&mut manager, 6);
manager.handle_enter();
manager.start_adding_launch_configuration();
manager.append_launch_configuration_input_character(' ');
manager.append_launch_configuration_input_character('n');
manager.append_launch_configuration_input_character('v');
manager.append_launch_configuration_input_character('i');
manager.append_launch_configuration_input_character('m');
manager.append_launch_configuration_input_character(' ');
manager.confirm_launch_configuration_input(&services).await;
assert_eq!(manager.launch_configuration, "nvim");
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
Some("nvim".to_string())
);
}
#[tokio::test]
async fn confirm_launch_configuration_input_edits_selected_command_and_persists_value() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
manager.launch_configuration = "cargo test\nnpm run dev".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
manager.next_launch_configuration_list_editor_item();
manager.start_editing_selected_launch_configuration();
for _ in 0.."npm run dev".chars().count() {
manager.remove_launch_configuration_input_character();
}
for character in "lazygit".chars() {
manager.append_launch_configuration_input_character(character);
}
manager.confirm_launch_configuration_input(&services).await;
assert_eq!(manager.launch_configuration, "cargo test\nlazygit");
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
Some("cargo test\nlazygit".to_string())
);
}
#[tokio::test]
async fn confirm_launch_configuration_input_drops_empty_edited_command() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
manager.launch_configuration = "cargo test\nnpm run dev".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
manager.start_editing_selected_launch_configuration();
for _ in 0.."cargo test".chars().count() {
manager.remove_launch_configuration_input_character();
}
manager.confirm_launch_configuration_input(&services).await;
assert_eq!(manager.launch_configuration, "npm run dev");
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
Some("npm run dev".to_string())
);
}
#[tokio::test]
async fn delete_selected_launch_configuration_persists_remaining_commands() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
manager.launch_configuration = "cargo test\nnpm run dev\nlazygit".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
manager.next_launch_configuration_list_editor_item();
manager
.delete_selected_launch_configuration(&services)
.await;
assert_eq!(manager.launch_configuration, "cargo test\nlazygit");
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
Some("cargo test\nlazygit".to_string())
);
}
#[tokio::test]
async fn move_selected_launch_configuration_down_persists_reordered_commands() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
manager.launch_configuration = "cargo test\nnpm run dev\nlazygit".to_string();
select_row(&mut manager, 6);
manager.handle_enter();
manager
.move_selected_launch_configuration_down(&services)
.await;
let editor = manager
.launch_configuration_list_editor()
.expect("expected launch-configuration list editor");
assert_eq!(
manager.launch_configuration,
"npm run dev\ncargo test\nlazygit"
);
assert_eq!(editor.selected_index, 1);
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
Some("npm run dev\ncargo test\nlazygit".to_string())
);
}
#[tokio::test]
async fn selector_dropdown_selects_coauthor_setting_and_persists_value() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
select_row(&mut manager, 5);
manager.handle_enter();
manager.next_selector_dropdown_option();
manager.select_selector_dropdown_option(&services).await;
assert!(manager.include_coauthored_by_agentty);
assert!(!manager.is_selector_dropdown_open());
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::IncludeCoauthoredByAgentty)
.await
.expect("failed to load coauthor setting"),
Some("true".to_string())
);
}
#[tokio::test]
async fn selector_dropdown_selects_theme_setting_and_persists_value() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
select_row(&mut manager, 0);
manager.handle_enter();
let dropdown = manager
.selector_dropdown()
.expect("expected theme selector dropdown");
assert_eq!(dropdown.row_index, 0);
assert_eq!(dropdown.selected_index, 0);
assert_eq!(dropdown.options[1].label, "Agentty Green");
manager.next_selector_dropdown_option();
manager.select_selector_dropdown_option(&services).await;
let selected_theme = manager.theme;
let persisted_theme = services
.db()
.settings()
.get_setting(SettingName::Theme)
.await
.expect("failed to load theme setting");
assert_eq!(selected_theme, ColorTheme::Hacker);
assert_eq!(
persisted_theme,
Some(ColorTheme::Hacker.as_str().to_string())
);
}
#[tokio::test]
async fn launch_configuration_editor_apis_are_noops_without_open_editor() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
manager.start_adding_launch_configuration();
manager.start_editing_selected_launch_configuration();
manager.append_launch_configuration_input_character('n');
manager.remove_launch_configuration_input_character();
manager.delete_launch_configuration_input_character();
manager.move_launch_configuration_input_cursor_left();
manager.move_launch_configuration_input_cursor_right();
manager.move_launch_configuration_input_cursor_home();
manager.move_launch_configuration_input_cursor_end();
manager.confirm_launch_configuration_input(&services).await;
manager
.delete_selected_launch_configuration(&services)
.await;
manager
.move_selected_launch_configuration_down(&services)
.await;
manager
.move_selected_launch_configuration_up(&services)
.await;
assert!(manager.launch_configuration.is_empty());
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LaunchConfiguration)
.await
.expect("failed to load launch configuration"),
None
);
}
#[tokio::test]
async fn selector_dropdown_persists_last_used_flag_and_explicit_smart_model() {
let (services, project_id) = test_services().await;
let mut manager = SettingsManager::new(&services, project_id).await;
let options = selectable_model_options(AgentKind::ALL);
let last_option = *options.last().expect("model options should not be empty");
manager.default_smart_selection = last_option.selection();
manager.use_last_used_model_as_default = false;
select_row(&mut manager, 2);
manager.handle_enter();
let dropdown = manager
.selector_dropdown()
.expect("expected smart model selector dropdown");
assert_eq!(dropdown.selected_index, options.len() - 1);
manager.next_selector_dropdown_option();
manager.select_selector_dropdown_option(&services).await;
assert!(manager.use_last_used_model_as_default);
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LastUsedModelAsDefault)
.await
.expect("failed to load last-used flag"),
Some("true".to_string())
);
manager.handle_enter();
manager.next_selector_dropdown_option();
manager.select_selector_dropdown_option(&services).await;
assert!(!manager.use_last_used_model_as_default);
assert_eq!(manager.default_smart_selection, options[0].selection());
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::DefaultSmartModel)
.await
.expect("failed to load smart model"),
Some(options[0].model.as_str().to_string())
);
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::DefaultSmartAgent)
.await
.expect("failed to load smart agent"),
Some(options[0].agent_kind.name().to_string())
);
assert_eq!(
services
.db()
.settings()
.get_project_setting(project_id, SettingName::LastUsedModelAsDefault)
.await
.expect("failed to load last-used flag"),
Some("false".to_string())
);
}
#[tokio::test]
async fn load_default_smart_model_setting_falls_back_to_available_backend() {
let (mut services, project_id) = test_services().await;
let available_agent_kinds = vec![AgentKind::Codex];
services = AppServices::new_with_agent_clis(
services.base_path().to_path_buf(),
services.clock(),
services.event_sender(),
crate::app::service::AppServiceDeps {
app_server_client_override: services.app_server_client_override(),
available_agent_kinds: available_agent_kinds.clone(),
clipboard_image_client_override: None,
fs_client: services.fs_client(),
git_client: services.git_client(),
repositories: services.db().clone(),
review_request_client: services.review_request_client(),
},
crate::domain::agent::AgentCliInfo::from_kinds(&available_agent_kinds),
);
services
.db()
.settings()
.upsert_project_setting(
project_id,
SettingName::DefaultSmartModel,
AgentModel::Gemini31ProPreview.as_str(),
)
.await
.expect("failed to persist unavailable smart model");
let loaded_model = load_default_smart_model_setting(
&services,
Some(project_id),
AgentKind::Antigravity.default_model(),
)
.await;
assert_eq!(loaded_model, AgentKind::Codex.default_model());
}
}