cc-switch-tui 0.1.2

All-in-One Assistant for Claude Code, Codex, Gemini, OpenCode, OpenClaw & Hermes
Documentation
use crate::{app_config::McpServer, cli::i18n::texts};
use serde_json::{json, Value};

use super::{
    FormFocus, FormMode, McpAddField, McpAddFormState, McpEnvVarRow, McpTransport, TextInput,
};

const MCP_TEMPLATES: [&str; 2] = ["Custom", "Filesystem (npx)"];

impl McpTransport {
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Stdio => "stdio",
            Self::Http => "http",
            Self::Sse => "sse",
        }
    }

    pub fn label(self) -> &'static str {
        self.as_str()
    }

    pub fn picker_index(self) -> usize {
        match self {
            Self::Stdio => 0,
            Self::Http => 1,
            Self::Sse => 2,
        }
    }

    pub fn from_picker_index(index: usize) -> Self {
        match index {
            1 => Self::Http,
            2 => Self::Sse,
            _ => Self::Stdio,
        }
    }

    pub fn from_server_spec(spec: &Value) -> Self {
        match spec.get("type").and_then(|value| value.as_str()) {
            Some("http") => Self::Http,
            Some("sse") => Self::Sse,
            Some("stdio") => Self::Stdio,
            None if spec.get("url").and_then(|value| value.as_str()).is_some() => Self::Http,
            _ => Self::Stdio,
        }
    }

    pub fn is_remote(self) -> bool {
        matches!(self, Self::Http | Self::Sse)
    }
}

impl McpAddFormState {
    pub fn new() -> Self {
        let mut form = Self {
            mode: FormMode::Add,
            focus: FormFocus::Templates,
            template_idx: 0,
            field_idx: 0,
            editing: false,
            extra: json!({}),
            id: TextInput::new(""),
            name: TextInput::new(""),
            server_type: McpTransport::Stdio,
            command: TextInput::new(""),
            args: TextInput::new(""),
            url: TextInput::new(""),
            env_rows: Vec::new(),
            apps: Default::default(),
            json_scroll: 0,
            initial_snapshot: Value::Null,
        };
        form.capture_initial_snapshot();
        form
    }

    pub fn from_server(server: &McpServer) -> Self {
        let mut form = Self::new();
        form.mode = FormMode::Edit {
            id: server.id.clone(),
        };
        form.focus = FormFocus::Fields;
        form.extra = serde_json::to_value(server).unwrap_or_else(|_| json!({}));
        form.id.set(server.id.clone());
        form.name.set(server.name.clone());
        form.apps = server.apps.clone();
        form.server_type = McpTransport::from_server_spec(&server.server);

        if let Some(command) = server
            .server
            .get("command")
            .and_then(|value| value.as_str())
        {
            form.command.set(command);
        }
        if let Some(args) = server.server.get("args").and_then(|value| value.as_array()) {
            let joined = args
                .iter()
                .filter_map(|value| value.as_str())
                .collect::<Vec<_>>()
                .join(" ");
            form.args.set(joined);
        }
        if let Some(url) = server.server.get("url").and_then(|value| value.as_str()) {
            form.url.set(url);
        }
        form.env_rows = load_env_rows(server);
        form.capture_initial_snapshot();

        form
    }

    fn capture_initial_snapshot(&mut self) {
        self.initial_snapshot = self.to_mcp_server_json_value();
    }

    pub fn rebase_initial_snapshot(&mut self) {
        self.capture_initial_snapshot();
    }

    pub fn has_unsaved_changes(&self) -> bool {
        self.to_mcp_server_json_value() != self.initial_snapshot
    }

    pub fn upsert_env_row(&mut self, row: Option<usize>, key: String, value: String) {
        let next = McpEnvVarRow { key, value };
        if let Some(idx) = row.filter(|idx| *idx < self.env_rows.len()) {
            self.env_rows[idx] = next;
        } else {
            self.env_rows.push(next);
        }
        self.env_rows
            .sort_by(|left, right| left.key.cmp(&right.key));
    }

    pub fn remove_env_row(&mut self, row: usize) {
        if row < self.env_rows.len() {
            self.env_rows.remove(row);
        }
    }

    pub fn env_summary(&self) -> String {
        match self.env_rows.len() {
            0 => texts::none().to_string(),
            1 => texts::tui_mcp_env_entry_count(1),
            count => texts::tui_mcp_env_entry_count(count),
        }
    }

    pub fn locked_id(&self) -> Option<&str> {
        match &self.mode {
            FormMode::Edit { id } => Some(id.as_str()),
            FormMode::Add => None,
        }
    }

    pub fn has_required_fields(&self) -> bool {
        !self.id.is_blank() && !self.name.is_blank()
    }

    pub fn template_count(&self) -> usize {
        MCP_TEMPLATES.len()
    }

    pub fn template_labels(&self) -> Vec<&'static str> {
        MCP_TEMPLATES.to_vec()
    }

    pub fn fields(&self) -> Vec<McpAddField> {
        let mut fields = vec![McpAddField::Id, McpAddField::Name, McpAddField::Type];

        if self.server_type.is_remote() {
            fields.push(McpAddField::Url);
        } else {
            fields.extend([McpAddField::Command, McpAddField::Args, McpAddField::Env]);
        }

        fields.extend([
            McpAddField::AppClaude,
            McpAddField::AppCodex,
            McpAddField::AppGemini,
            McpAddField::AppOpenCode,
            McpAddField::AppOpenClaw,
            McpAddField::AppHermes,
        ]);

        fields
    }

    pub fn input(&self, field: McpAddField) -> Option<&TextInput> {
        match field {
            McpAddField::Id => Some(&self.id),
            McpAddField::Name => Some(&self.name),
            McpAddField::Command => Some(&self.command),
            McpAddField::Args => Some(&self.args),
            McpAddField::Url => Some(&self.url),
            McpAddField::Type
            | McpAddField::Env
            | McpAddField::AppClaude
            | McpAddField::AppCodex
            | McpAddField::AppGemini
            | McpAddField::AppOpenCode
            | McpAddField::AppOpenClaw
            | McpAddField::AppHermes => None,
        }
    }

    pub fn input_mut(&mut self, field: McpAddField) -> Option<&mut TextInput> {
        match field {
            McpAddField::Id => Some(&mut self.id),
            McpAddField::Name => Some(&mut self.name),
            McpAddField::Command => Some(&mut self.command),
            McpAddField::Args => Some(&mut self.args),
            McpAddField::Url => Some(&mut self.url),
            McpAddField::Type
            | McpAddField::Env
            | McpAddField::AppClaude
            | McpAddField::AppCodex
            | McpAddField::AppGemini
            | McpAddField::AppOpenCode
            | McpAddField::AppOpenClaw
            | McpAddField::AppHermes => None,
        }
    }

    pub fn apply_template(&mut self, idx: usize) {
        let idx = idx.min(self.template_count().saturating_sub(1));
        self.template_idx = idx;

        if idx == 0 {
            if matches!(self.mode, FormMode::Add) {
                let defaults = Self::new();
                self.extra = defaults.extra;
                self.name = defaults.name;
                self.server_type = defaults.server_type;
                self.command = defaults.command;
                self.args = defaults.args;
                self.url = defaults.url;
                self.env_rows = defaults.env_rows;
                self.json_scroll = defaults.json_scroll;
            }
            return;
        }

        if idx == 1 {
            self.name.set("Filesystem");
            self.server_type = McpTransport::Stdio;
            self.command.set("npx");
            self.args
                .set("-y @modelcontextprotocol/server-filesystem /");
            self.url.set("");
        }
    }

    pub fn to_mcp_server_json_value(&self) -> Value {
        let args = self
            .args
            .value
            .split_whitespace()
            .map(|value| Value::String(value.to_string()))
            .collect::<Vec<_>>();

        let mut obj = match self.extra.clone() {
            Value::Object(map) => map,
            _ => serde_json::Map::new(),
        };

        obj.insert("id".to_string(), json!(self.id.value.trim()));
        obj.insert("name".to_string(), json!(self.name.value.trim()));

        let server_value = obj.entry("server".to_string()).or_insert_with(|| json!({}));
        if !server_value.is_object() {
            *server_value = json!({});
        }
        let server_obj = server_value
            .as_object_mut()
            .expect("server must be a JSON object");

        for key in ["type", "command", "args", "env", "url"] {
            server_obj.remove(key);
        }
        if self.server_type.is_remote() {
            server_obj.remove("cwd");
        } else {
            server_obj.remove("headers");
        }

        server_obj.insert("type".to_string(), json!(self.server_type.as_str()));
        if self.server_type.is_remote() {
            server_obj.insert("url".to_string(), json!(self.url.value.trim()));
        } else {
            server_obj.insert("command".to_string(), json!(self.command.value.trim()));
            server_obj.insert("args".to_string(), Value::Array(args));
            let env = self
                .env_rows
                .iter()
                .fold(serde_json::Map::new(), |mut map, row| {
                    map.insert(row.key.clone(), Value::String(row.value.clone()));
                    map
                });
            server_obj.remove("env");
            if !env.is_empty() {
                server_obj.insert("env".to_string(), Value::Object(env));
            }
        }

        obj.insert(
            "apps".to_string(),
            json!({
                "claude": self.apps.claude,
                "codex": self.apps.codex,
                "gemini": self.apps.gemini,
                "opencode": self.apps.opencode,
            }),
        );

        Value::Object(obj)
    }
}

fn load_env_rows(server: &McpServer) -> Vec<McpEnvVarRow> {
    let mut rows = server
        .server
        .get("env")
        .and_then(|value| value.as_object())
        .into_iter()
        .flat_map(|env| env.iter())
        .filter_map(|(key, value)| {
            value.as_str().map(|value| McpEnvVarRow {
                key: key.clone(),
                value: value.to_string(),
            })
        })
        .collect::<Vec<_>>();
    rows.sort_by(|left, right| left.key.cmp(&right.key));
    rows
}