tidev 0.1.0

A terminal-based AI coding agent
Documentation
use std::collections::BTreeMap;

use crate::config::{ModelConfig, ProviderConfig};

use super::NewModelDraft;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditProviderStep {
    DisplayName,
    BaseUrl,
    ApiKey,
    ModelList,
    ConfirmDeleteModel,
}

impl EditProviderStep {
    pub fn title(self) -> &'static str {
        match self {
            Self::DisplayName => "Display name",
            Self::BaseUrl => "Base URL",
            Self::ApiKey => "API key",
            Self::ModelList => "Models",
            Self::ConfirmDeleteModel => "Delete model",
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            Self::DisplayName => "Provider display name",
            Self::BaseUrl => "Base URL",
            Self::ApiKey => "API key",
            Self::ModelList => "Models",
            Self::ConfirmDeleteModel => "Delete model",
        }
    }

    pub fn placeholder(self) -> &'static str {
        match self {
            Self::DisplayName => "provider display name",
            Self::BaseUrl => "https://api.openai.com/v1",
            Self::ApiKey => "Leave blank to keep the current key",
            Self::ModelList => "Use Enter / n / d / s",
            Self::ConfirmDeleteModel => "y or n",
        }
    }

    pub fn help(self) -> &'static str {
        match self {
            Self::DisplayName => "Shown in the TUI and session metadata.",
            Self::BaseUrl => "Use an OpenAI-compatible chat completions endpoint.",
            Self::ApiKey => "Leave blank to keep the existing key in auth.json.",
            Self::ModelList => "Enter edits, n adds, d deletes, s saves.",
            Self::ConfirmDeleteModel => "Press y to delete, or n / Esc to keep it.",
        }
    }

    pub fn next(self) -> Option<Self> {
        match self {
            Self::DisplayName => Some(Self::BaseUrl),
            Self::BaseUrl => Some(Self::ApiKey),
            Self::ApiKey => Some(Self::ModelList),
            Self::ModelList | Self::ConfirmDeleteModel => None,
        }
    }

    pub fn is_secret(self) -> bool {
        matches!(self, Self::ApiKey)
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum EditModelStep {
    ModelId,
    ModelDisplayName,
    ContextWindow,
    MaxOutputTokens,
    Temperature,
}

impl EditModelStep {
    pub fn title(self) -> &'static str {
        match self {
            Self::ModelId => "Model id",
            Self::ModelDisplayName => "Model display name",
            Self::ContextWindow => "Context window",
            Self::MaxOutputTokens => "Max output tokens",
            Self::Temperature => "Temperature",
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            Self::ModelId => "Model id",
            Self::ModelDisplayName => "Model display name",
            Self::ContextWindow => "Context window",
            Self::MaxOutputTokens => "Max output tokens",
            Self::Temperature => "Temperature",
        }
    }

    pub fn placeholder(self) -> &'static str {
        match self {
            Self::ModelId => "model id",
            Self::ModelDisplayName => "model display name",
            Self::ContextWindow => "128000",
            Self::MaxOutputTokens => "32768",
            Self::Temperature => "0.7",
        }
    }

    pub fn help(self) -> &'static str {
        match self {
            Self::ModelId => "The exact model id the provider expects.",
            Self::ModelDisplayName => "Shown in the TUI and session metadata.",
            Self::ContextWindow => "Total token budget for the model context.",
            Self::MaxOutputTokens => "Maximum tokens the model may generate per turn.",
            Self::Temperature => "Usually 0.0 to 1.0 for deterministic coding help.",
        }
    }

    pub fn next(self, _editing_existing: bool) -> Option<Self> {
        match self {
            Self::ModelId => Some(Self::ModelDisplayName),
            Self::ModelDisplayName => Some(Self::ContextWindow),
            Self::ContextWindow => Some(Self::MaxOutputTokens),
            Self::MaxOutputTokens => Some(Self::Temperature),
            Self::Temperature => None,
        }
    }

    pub fn is_secret(self) -> bool {
        false
    }
}

#[derive(Clone, Debug)]
pub struct EditProviderDraft {
    pub display_name: String,
    pub base_url: String,
    pub api_key: String,
    pub existing_api_key: Option<String>,
    pub models: BTreeMap<String, ModelConfig>,
    pub selected_model_index: usize,
    pub model: NewModelDraft,
    pub editing_model_id: Option<String>,
    pub pending_delete_model_id: Option<String>,
}

impl EditProviderDraft {
    pub fn from_provider(provider: &ProviderConfig, api_key: Option<String>) -> Self {
        Self {
            display_name: provider.display_name.clone(),
            base_url: provider.base_url.clone(),
            api_key: String::new(),
            existing_api_key: api_key,
            models: provider.models.clone(),
            selected_model_index: 0,
            model: NewModelDraft::default(),
            editing_model_id: None,
            pending_delete_model_id: None,
        }
    }

    pub fn current_value(&self, step: EditProviderStep) -> String {
        match step {
            EditProviderStep::DisplayName => self.display_name.clone(),
            EditProviderStep::BaseUrl => self.base_url.clone(),
            EditProviderStep::ApiKey => String::new(),
            EditProviderStep::ModelList | EditProviderStep::ConfirmDeleteModel => String::new(),
        }
    }

    pub fn apply_step(&mut self, step: EditProviderStep, input: &str) -> anyhow::Result<()> {
        use super::{non_empty, normalize_base_url};
        let value = input.trim();

        match step {
            EditProviderStep::DisplayName => {
                self.display_name = non_empty(value, "provider display name")?.to_string();
            }
            EditProviderStep::BaseUrl => {
                self.base_url = normalize_base_url(value)?;
            }
            EditProviderStep::ApiKey => {
                if !value.is_empty() {
                    self.api_key = value.to_string();
                }
            }
            EditProviderStep::ModelList | EditProviderStep::ConfirmDeleteModel => {}
        }

        Ok(())
    }

    pub fn selected_model_id(&self) -> Option<String> {
        self.models.keys().nth(self.selected_model_index).cloned()
    }

    pub fn selected_model_config(&self) -> Option<(String, ModelConfig)> {
        self.selected_model_id().and_then(|model_id| {
            self.models
                .get(&model_id)
                .cloned()
                .map(|model| (model_id, model))
        })
    }

    pub fn move_selection_up(&mut self) {
        let count = self.models.len();
        if count == 0 {
            self.selected_model_index = 0;
        } else if self.selected_model_index == 0 {
            self.selected_model_index = count.saturating_sub(1);
        } else {
            self.selected_model_index -= 1;
        }
    }

    pub fn move_selection_down(&mut self) {
        let count = self.models.len();
        if count == 0 {
            self.selected_model_index = 0;
        } else {
            self.selected_model_index = (self.selected_model_index + 1) % count;
        }
    }

    pub fn begin_new_model(&mut self) {
        self.model = NewModelDraft::default();
        self.editing_model_id = None;
    }

    pub fn begin_edit_model(&mut self, model_id: &str) -> anyhow::Result<()> {
        let model = self
            .models
            .get(model_id)
            .ok_or_else(|| anyhow::anyhow!("unknown model '{model_id}'"))?;

        self.model = NewModelDraft::from_model(model_id.to_string(), model);
        self.editing_model_id = Some(model_id.to_string());
        Ok(())
    }

    pub fn finish_current_model(&mut self) -> anyhow::Result<()> {
        let editing_model_id = self.editing_model_id.clone();
        let (model_id, model_config) = self.model.clone().into_model_config();
        let model_id = editing_model_id.unwrap_or(model_id);

        if model_id.trim().is_empty() {
            anyhow::bail!("model id cannot be empty");
        }

        if self.editing_model_id.is_none() && self.models.contains_key(&model_id) {
            anyhow::bail!("model '{model_id}' already exists");
        }

        self.models.insert(model_id.clone(), model_config);
        self.selected_model_index = self
            .models
            .keys()
            .position(|candidate| candidate == &model_id)
            .unwrap_or(0);
        self.model = NewModelDraft::default();
        self.editing_model_id = None;
        Ok(())
    }

    pub fn request_delete_selected_model(&mut self) -> anyhow::Result<String> {
        let model_id = self
            .selected_model_id()
            .ok_or_else(|| anyhow::anyhow!("no model selected"))?;
        self.pending_delete_model_id = Some(model_id.clone());
        Ok(model_id)
    }

    pub fn confirm_delete_selected_model(&mut self) -> anyhow::Result<String> {
        let Some(model_id) = self.pending_delete_model_id.take() else {
            anyhow::bail!("no pending model deletion");
        };

        if self.models.len() <= 1 {
            anyhow::bail!("at least one model must remain");
        }

        self.models.remove(&model_id);

        if self.selected_model_index >= self.models.len() {
            self.selected_model_index = self.models.len().saturating_sub(1);
        }

        Ok(model_id)
    }

    pub fn into_provider_config(self) -> anyhow::Result<(ProviderConfig, String)> {
        let display_name = if self.display_name.is_empty() {
            "Unnamed provider".to_string()
        } else {
            self.display_name
        };

        let api_key = if self.api_key.trim().is_empty() {
            self.existing_api_key.unwrap_or_default()
        } else {
            self.api_key
        };

        if api_key.trim().is_empty() {
            anyhow::bail!("API key cannot be empty");
        }

        Ok((
            ProviderConfig {
                display_name,
                base_url: self.base_url,
                api_type: None,
                models: self.models,
            },
            api_key,
        ))
    }
}