diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 32e1881f..bf1b2d17 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3008,6 +3008,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ + "indexmap", "itoa", "memchr", "ryu", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 53772f3d..9a4aa45f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -24,7 +24,7 @@ test-hooks = [] [dependencies] # Core dependencies -serde_json = "1.0" +serde_json = { version = "1.0", features = ["preserve_order"] } serde = { version = "1.0", features = ["derive"] } log = "0.4" chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 8c2045ac..736230a5 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; +use crate::prompt_files::prompt_file_path; use crate::services::skill::SkillStore; /// MCP 服务器应用状态(标记应用到哪些客户端) @@ -27,6 +28,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -38,6 +40,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } @@ -57,6 +60,9 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.hermes { + apps.push(AppType::Hermes); + } apps } @@ -77,6 +83,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl SkillApps { @@ -86,6 +94,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -96,12 +105,13 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes } pub fn only(app: &AppType) -> Self { @@ -125,6 +135,7 @@ impl SkillApps { self.codex |= other.codex; self.gemini |= other.gemini; self.opencode |= other.opencode; + self.hermes |= other.hermes; } } @@ -223,6 +234,8 @@ pub struct McpRoot { #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub opencode: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, } @@ -236,6 +249,7 @@ impl Default for McpRoot { codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), + hermes: McpConfig::default(), openclaw: McpConfig::default(), } } @@ -260,6 +274,8 @@ pub struct PromptRoot { #[serde(default)] pub opencode: PromptConfig, #[serde(default)] + pub hermes: PromptConfig, + #[serde(default)] pub openclaw: PromptConfig, } @@ -275,6 +291,7 @@ pub enum AppType { Codex, Gemini, OpenCode, + Hermes, OpenClaw, } @@ -285,12 +302,16 @@ impl AppType { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", } } pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode | AppType::OpenClaw) + matches!( + self, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw + ) } pub fn supports_failover(&self) -> bool { @@ -303,6 +324,7 @@ impl AppType { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] .into_iter() @@ -325,14 +347,15 @@ impl FromStr for AppType { "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), + "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw." ), )), } @@ -354,6 +377,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, } @@ -366,6 +392,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), + AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), } } @@ -377,6 +404,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, + AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, } } @@ -418,6 +446,7 @@ impl Default for MultiAppConfig { apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); + apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); Self { @@ -512,6 +541,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("hermes") { + config + .apps + .insert("hermes".to_string(), ProviderManager::default()); + updated = true; + } + if !config.apps.contains_key("openclaw") { config .apps @@ -583,6 +619,7 @@ impl MultiAppConfig { AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, + AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, } } @@ -594,10 +631,137 @@ impl MultiAppConfig { AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, + AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, } } + /// 创建默认配置并自动导入已存在的提示词文件 + fn default_with_auto_import() -> Result { + log::info!("首次启动,创建默认配置并检测提示词文件"); + + let mut config = Self::default(); + + // 为每个应用尝试自动导入提示词 + Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; + + Ok(config) + } + + /// 已存在配置文件时的 Prompt 自动导入逻辑 + /// + /// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。 + /// 判定规则: + /// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户) + /// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md) + /// + /// 返回值: + /// - Ok(true) 表示至少有一个应用成功导入了提示词 + /// - Ok(false) 表示无需导入或未导入任何内容 + fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result { + // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入 + if !self.prompts.claude.prompts.is_empty() + || !self.prompts.codex.prompts.is_empty() + || !self.prompts.gemini.prompts.is_empty() + || !self.prompts.opencode.prompts.is_empty() + || !self.prompts.hermes.prompts.is_empty() + || !self.prompts.openclaw.prompts.is_empty() + { + return Ok(false); + } + + log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); + + let mut imported = false; + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes, + AppType::OpenClaw, + ] { + // 复用已有的单应用导入逻辑 + if Self::auto_import_prompt_if_exists(self, app)? { + imported = true; + } + } + + Ok(imported) + } + + /// 检查并自动导入单个应用的提示词文件 + /// + /// 返回值: + /// - Ok(true) 表示成功导入了非空文件 + /// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败) + fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result { + let file_path = prompt_file_path(&app)?; + + // 检查文件是否存在 + if !file_path.exists() { + log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}"); + return Ok(false); + } + + // 读取文件内容 + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); + return Ok(false); // 失败时不中断,继续处理其他应用 + } + }; + + // 检查内容是否为空 + if content.trim().is_empty() { + log::debug!("提示词文件内容为空,跳过导入: {file_path:?}"); + return Ok(false); + } + + log::info!("发现提示词文件,自动导入: {file_path:?}"); + + // 创建提示词对象 + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let id = format!("auto-imported-{timestamp}"); + let prompt = crate::prompt::Prompt { + id: id.clone(), + name: format!( + "Auto-imported Prompt {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content, + description: Some("Automatically imported on first launch".to_string()), + enabled: true, // 自动启用 + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // 插入到对应的应用配置中 + let prompts = match app { + AppType::Claude => &mut config.prompts.claude.prompts, + AppType::Codex => &mut config.prompts.codex.prompts, + AppType::Gemini => &mut config.prompts.gemini.prompts, + AppType::OpenCode => &mut config.prompts.opencode.prompts, + AppType::Hermes => &mut config.prompts.hermes.prompts, + AppType::OpenClaw => &mut config.prompts.openclaw.prompts, + }; + + prompts.insert(id, prompt); + + log::info!("自动导入完成: {}", app.as_str()); + Ok(true) + } + /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构 /// /// 迁移策略: @@ -623,12 +787,14 @@ impl MultiAppConfig { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, + AppType::Hermes => &self.mcp.hermes.servers, AppType::OpenClaw => continue, }; @@ -733,6 +899,7 @@ impl MultiAppConfig { self.mcp.codex = McpConfig::default(); self.mcp.gemini = McpConfig::default(); self.mcp.opencode = McpConfig::default(); + self.mcp.hermes = McpConfig::default(); Ok(true) } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index d4c923b1..82d3738b 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -180,8 +180,12 @@ fn canonical_common_snippet(app_type: AppType, raw: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(trimmed).map_err(|e| { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { + let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; if !value.is_object() { diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index e9ac5bbc..f14cd379 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/commands/hermes.rs b/src-tauri/src/cli/commands/hermes.rs new file mode 100644 index 00000000..5cd9b80a --- /dev/null +++ b/src-tauri/src/cli/commands/hermes.rs @@ -0,0 +1,213 @@ +//! Hermes-specific CLI commands: memory / user profile management. +//! +//! Mirrors the memory subset of upstream `cc-switch/src-tauri/src/commands/hermes.rs`. +//! The web UI / dashboard launcher lives in the GUI; the CLI only exposes +//! the local-file interactions. + +use std::io::{self, Read}; + +use clap::Subcommand; + +use crate::cli::ui::{info, success, warning}; +use crate::error::AppError; +use crate::hermes_config::{ + self, get_hermes_dir, read_memory, read_memory_limits, set_memory_enabled, write_memory, + MemoryKind, +}; + +#[derive(Subcommand)] +pub enum HermesCommand { + /// Hermes memory blob (MEMORY.md / USER.md) operations + #[command(subcommand)] + Memory(MemoryCommand), +} + +#[derive(Subcommand)] +pub enum MemoryCommand { + /// Print the content of a memory file to stdout + Show { + /// Memory kind to read + #[arg(value_enum, default_value_t = MemoryKindArg::Memory)] + kind: MemoryKindArg, + }, + /// Write content into a memory file + /// + /// If `--content` is omitted, the new content is read from stdin. + Set { + /// Memory kind to write + #[arg(value_enum)] + kind: MemoryKindArg, + /// Inline content (takes precedence over stdin) + #[arg(long)] + content: Option, + }, + /// Clear a memory file (writes empty content) + Clear { + /// Memory kind to clear + #[arg(value_enum)] + kind: MemoryKindArg, + /// Confirm the destructive operation + #[arg(long)] + yes: bool, + }, + /// Enable a memory blob (writes `memory_enabled` / `user_profile_enabled = true`) + Enable { + /// Memory kind to enable + #[arg(value_enum)] + kind: MemoryKindArg, + }, + /// Disable a memory blob (writes the corresponding `*_enabled = false`) + Disable { + /// Memory kind to disable + #[arg(value_enum)] + kind: MemoryKindArg, + }, + /// Show character limits and enable flags for both memory blobs + Limits, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum MemoryKindArg { + Memory, + User, +} + +impl From for MemoryKind { + fn from(value: MemoryKindArg) -> Self { + match value { + MemoryKindArg::Memory => MemoryKind::Memory, + MemoryKindArg::User => MemoryKind::User, + } + } +} + +fn ensure_hermes_dir_exists() -> Result<(), AppError> { + if get_hermes_dir().exists() { + return Ok(()); + } + Err(AppError::localized( + "hermes.dir.missing", + format!("Hermes 配置目录不存在:{}", get_hermes_dir().display()), + format!( + "Hermes config dir not found: {}", + get_hermes_dir().display() + ), + )) +} + +pub fn execute(cmd: HermesCommand) -> Result<(), AppError> { + match cmd { + HermesCommand::Memory(memory_cmd) => execute_memory(memory_cmd), + } +} + +fn execute_memory(cmd: MemoryCommand) -> Result<(), AppError> { + ensure_hermes_dir_exists()?; + match cmd { + MemoryCommand::Show { kind } => show_memory(kind.into()), + MemoryCommand::Set { kind, content } => set_memory_cmd(kind.into(), content), + MemoryCommand::Clear { kind, yes } => clear_memory(kind.into(), yes), + MemoryCommand::Enable { kind } => toggle_memory(kind.into(), true), + MemoryCommand::Disable { kind } => toggle_memory(kind.into(), false), + MemoryCommand::Limits => print_limits(), + } +} + +fn show_memory(kind: MemoryKind) -> Result<(), AppError> { + let content = read_memory(kind)?; + if content.is_empty() { + println!( + "{}", + info(&format!( + "Hermes {} memory is empty (file not created yet)", + kind.as_str() + )) + ); + } else { + print!("{content}"); + if !content.ends_with('\n') { + println!(); + } + } + Ok(()) +} + +fn set_memory_cmd(kind: MemoryKind, content: Option) -> Result<(), AppError> { + let content = match content { + Some(value) => value, + None => { + let mut buf = String::new(); + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| AppError::Config(format!("Failed to read stdin: {e}")))?; + buf + } + }; + + write_memory(kind, &content)?; + println!( + "{}", + success(&format!( + "✓ Wrote {} bytes to Hermes {} memory", + content.len(), + kind.as_str() + )) + ); + Ok(()) +} + +fn clear_memory(kind: MemoryKind, yes: bool) -> Result<(), AppError> { + if !yes { + println!( + "{}", + warning(&format!( + "Refusing to clear Hermes {} memory without --yes", + kind.as_str() + )) + ); + return Ok(()); + } + write_memory(kind, "")?; + println!( + "{}", + success(&format!("✓ Cleared Hermes {} memory", kind.as_str())) + ); + Ok(()) +} + +fn toggle_memory(kind: MemoryKind, enabled: bool) -> Result<(), AppError> { + set_memory_enabled(kind, enabled)?; + let verb = if enabled { "Enabled" } else { "Disabled" }; + println!( + "{}", + success(&format!("✓ {verb} Hermes {} memory", kind.as_str())) + ); + Ok(()) +} + +fn print_limits() -> Result<(), AppError> { + let limits = read_memory_limits()?; + println!("Hermes memory limits:"); + println!( + " memory: {} chars (enabled: {})", + limits.memory, limits.memory_enabled + ); + println!( + " user: {} chars (enabled: {})", + limits.user, limits.user_enabled + ); + + let memory_len = read_memory(MemoryKind::Memory) + .map(|s| s.len()) + .unwrap_or(0); + let user_len = read_memory(MemoryKind::User).map(|s| s.len()).unwrap_or(0); + + println!(); + println!("Current usage (file size in bytes; Hermes truncates at character budget on load):"); + println!(" memory: {memory_len} bytes"); + println!(" user: {user_len} bytes"); + + // Avoid `unused import` if the helper isn't used elsewhere in this file. + let _ = hermes_config::get_hermes_config_path(); + Ok(()) +} diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index 34639b3c..3a253b02 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -75,7 +75,9 @@ fn list_servers(app_type: AppType) -> Result<(), AppError> { // 创建表格 let mut table = create_table(); - table.set_header(vec!["ID", "Name", "Claude", "Codex", "Gemini", "Tags"]); + table.set_header(vec![ + "ID", "Name", "Claude", "Codex", "Gemini", "OpenCode", "Hermes", "Tags", + ]); // 按 ID 排序 let mut server_list: Vec<_> = servers.into_iter().collect(); @@ -85,6 +87,8 @@ fn list_servers(app_type: AppType) -> Result<(), AppError> { let claude_marker = if server.apps.claude { "✓" } else { " " }; let codex_marker = if server.apps.codex { "✓" } else { " " }; let gemini_marker = if server.apps.gemini { "✓" } else { " " }; + let opencode_marker = if server.apps.opencode { "✓" } else { " " }; + let hermes_marker = if server.apps.hermes { "✓" } else { " " }; let tags = server.tags.join(", "); let row = vec![ @@ -93,6 +97,8 @@ fn list_servers(app_type: AppType) -> Result<(), AppError> { claude_marker.to_string(), codex_marker.to_string(), gemini_marker.to_string(), + opencode_marker.to_string(), + hermes_marker.to_string(), tags, ]; @@ -140,6 +146,16 @@ fn delete_server(id: &str) -> Result<(), AppError> { } else { None }, + if server.apps.opencode { + Some("OpenCode") + } else { + None + }, + if server.apps.hermes { + Some("Hermes") + } else { + None + }, ] .into_iter() .flatten() @@ -264,7 +280,8 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> { AppType::Claude => McpService::import_from_claude(&state)?, AppType::Codex => McpService::import_from_codex(&state)?, AppType::Gemini => McpService::import_from_gemini(&state)?, - AppType::OpenCode => 0, + AppType::OpenCode => McpService::import_from_opencode(&state)?, + AppType::Hermes => McpService::import_from_hermes(&state)?, AppType::OpenClaw => 0, }; diff --git a/src-tauri/src/cli/commands/mod.rs b/src-tauri/src/cli/commands/mod.rs index 6622ef9c..5a39e69b 100644 --- a/src-tauri/src/cli/commands/mod.rs +++ b/src-tauri/src/cli/commands/mod.rs @@ -6,6 +6,7 @@ pub mod config_webdav; pub mod daemon; pub mod env; pub mod failover; +pub mod hermes; pub mod internal; pub mod mcp; pub mod prompts; diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index c01c3a6c..3f6ac1f6 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -44,7 +44,7 @@ pub fn common_snippet_has_effective_config( .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -169,6 +169,7 @@ pub fn prompt_settings_config_for_add( (AppType::Codex, ProviderAddMode::ThirdParty) => prompt_codex_config(None), (AppType::Gemini, _) => prompt_gemini_config(None), (AppType::OpenCode, _) => Ok(json!({})), + (AppType::Hermes, _) => Ok(json!({})), (AppType::OpenClaw, _) => Ok(json!({})), } } @@ -405,6 +406,7 @@ pub fn prompt_settings_config( } AppType::Gemini => prompt_gemini_config(current), AppType::OpenCode => Ok(current.cloned().unwrap_or_else(|| json!({}))), + AppType::Hermes => Ok(current.cloned().unwrap_or_else(|| json!({}))), AppType::OpenClaw => Ok(current.cloned().unwrap_or_else(|| json!({}))), } } @@ -924,6 +926,49 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Hermes => { + if let Some(api_key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + println!( + " {}: {}", + texts::api_key_display_label(), + mask_api_key(api_key) + ); + } + if let Some(base_url) = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::base_url_display_label(), base_url); + } + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::model_label(), model); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_object()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_array()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } + } AppType::OpenClaw => { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 0e8374c2..61ca3ba2 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -341,6 +341,21 @@ fn model_fetch_target( })?, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Hermes => Ok(ModelFetchTarget { + base_url, + auth_value: provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + AppError::Message(format!("Missing API key for provider '{}'", provider.id)) + })?, + strategy: ProviderModelFetchStrategy::Bearer, + }), AppType::OpenClaw => Ok(ModelFetchTarget { base_url, auth_value: provider diff --git a/src-tauri/src/cli/commands/skills.rs b/src-tauri/src/cli/commands/skills.rs index 06e0a927..3b263b09 100644 --- a/src-tauri/src/cli/commands/skills.rs +++ b/src-tauri/src/cli/commands/skills.rs @@ -131,6 +131,7 @@ fn list_installed() -> Result<(), AppError> { "Codex", "Gemini", "OpenCode", + "Hermes", ]); for skill in skills { table.add_row(vec![ @@ -140,6 +141,7 @@ fn list_installed() -> Result<(), AppError> { if skill.apps.codex { "✓" } else { " " }.to_string(), if skill.apps.gemini { "✓" } else { " " }.to_string(), if skill.apps.opencode { "✓" } else { " " }.to_string(), + if skill.apps.hermes { "✓" } else { " " }.to_string(), ]); } @@ -267,8 +269,12 @@ fn show_skill_info(spec: &str) -> Result<(), AppError> { println!("Desc: {}", desc); } println!( - "Enabled: claude={} codex={} gemini={} opencode={}", - record.apps.claude, record.apps.codex, record.apps.gemini, record.apps.opencode + "Enabled: claude={} codex={} gemini={} opencode={} hermes={}", + record.apps.claude, + record.apps.codex, + record.apps.gemini, + record.apps.opencode, + record.apps.hermes ); Ok(()) diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 198e53a0..21e789ca 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,6 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index 5180fb3d..de2ccae2 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -571,6 +571,18 @@ pub mod texts { } } + pub fn tui_help_text_for_app(app_type: &crate::app_config::AppType) -> &'static str { + if matches!(app_type, crate::app_config::AppType::Hermes) { + if is_chinese() { + "[ ] 切换应用\n←→ 切换菜单/内容焦点\n↑↓ 移动\n/ 过滤\nEsc 返回\n? 显示/关闭帮助\n\n文本输入:Ctrl+A/E 行首/行尾,Ctrl+U/K 删除行片段,Ctrl+W 删除前词,Alt+B/F 按词移动\n\n页面快捷键(在页面内容区顶部显示):\n- 供应商:Enter 详情,Space 添加/移除,a 新增,e 编辑,d 删除,t 测试,r 刷新,f 管理故障转移,x 启用\n- 供应商详情:Space 添加/移除,e 编辑,t 测试,r 刷新,f 管理故障转移,x 启用\n- MCP:x 启用/禁用(当前应用),m 选择应用,a 添加,e 编辑,i 导入已有,d 删除\n- 记忆管理:Enter 编辑,Space/x 启用/禁用,o 打开目录\n- 技能:Enter 详情,x 启用/禁用(当前应用),m 选择应用,d 卸载,i 导入已有\n- 设置:Enter 应用" + } else { + "[ ] switch app\n←→ focus menu/content\n↑↓ move\n/ filter\nEsc back\n? toggle help\n\nText input: Ctrl+A/E move line, Ctrl+U/K delete line parts, Ctrl+W delete word, Alt+B/F move word\n\nPage keys (shown at the top of each page):\n- Providers: Enter details, Space add/remove, a add, e edit, d delete, t test, r refresh, f manage failover, x enable\n- Provider Detail: Space add/remove, e edit, t test, r refresh, f manage failover, x enable\n- MCP: x toggle current, m select apps, a add, e edit, i import existing, d delete\n- Memory: Enter edit, Space/x toggle, o open directory\n- Skills: Enter details, x toggle current, m select apps, d uninstall, i import existing\n- Settings: Enter apply" + } + } else { + tui_help_text() + } + } + pub fn tui_confirm_title() -> &'static str { if is_chinese() { "确认" @@ -1638,6 +1650,127 @@ pub mod texts { } } + pub fn tui_label_hermes_api_mode() -> &'static str { + if is_chinese() { + "API 模式" + } else { + "API Mode" + } + } + + pub fn tui_label_hermes_provider_key() -> &'static str { + if is_chinese() { + "供应商标识" + } else { + "Provider Key" + } + } + + pub fn tui_label_hermes_base_url() -> &'static str { + if is_chinese() { + "API 端点" + } else { + "API Endpoint" + } + } + + pub fn tui_label_hermes_models() -> &'static str { + if is_chinese() { + "模型列表" + } else { + "Models" + } + } + + pub fn tui_label_hermes_rate_limit_delay() -> &'static str { + if is_chinese() { + "请求间隔(秒)" + } else { + "Rate limit delay (seconds)" + } + } + + pub fn tui_hint_hermes_rate_limit_delay() -> &'static str { + if is_chinese() { + "连续请求间的最小间隔秒数(可选)。留空表示无限制。" + } else { + "Minimum delay in seconds between consecutive requests (optional). Leave empty for no limit." + } + } + + pub fn tui_hermes_rate_limit_delay_invalid() -> &'static str { + if is_chinese() { + "请求间隔必须是大于等于 0 的数字" + } else { + "Rate limit delay must be a number greater than or equal to 0" + } + } + + pub fn tui_hermes_provider_key_invalid() -> &'static str { + if is_chinese() { + "供应商标识只能包含小写字母、数字和连字符" + } else { + "Provider key can only contain lowercase letters, numbers, and hyphens" + } + } + + pub fn tui_hermes_base_url_required() -> &'static str { + if is_chinese() { + "API 端点不能为空" + } else { + "API endpoint is required" + } + } + + pub fn tui_hermes_base_url_scheme() -> &'static str { + if is_chinese() { + "请使用 http:// 或 https:// 开头的地址" + } else { + "Use an http:// or https:// address" + } + } + + pub fn tui_hermes_base_url_invalid() -> &'static str { + if is_chinese() { + "API 端点不是有效的 URL" + } else { + "API endpoint is not a valid URL" + } + } + + pub fn tui_hermes_api_mode_value(api_mode: &str) -> &'static str { + match api_mode { + "codex_responses" => { + if is_chinese() { + "OpenAI Responses" + } else { + "OpenAI Responses" + } + } + "anthropic_messages" => { + if is_chinese() { + "Anthropic Messages" + } else { + "Anthropic Messages" + } + } + "bedrock_converse" => { + if is_chinese() { + "AWS Bedrock Converse" + } else { + "AWS Bedrock Converse" + } + } + _ => { + if is_chinese() { + "OpenAI Chat Completions" + } else { + "OpenAI Chat Completions" + } + } + } + } + pub fn tui_label_openclaw_status() -> &'static str { if is_chinese() { "状态" @@ -1710,6 +1843,14 @@ pub mod texts { } } + pub fn tui_provider_status_in_use() -> &'static str { + if is_chinese() { + "已在用" + } else { + "in use" + } + } + pub fn tui_openclaw_status_in_config_and_saved() -> &'static str { if is_chinese() { "配置中 + 已保存" @@ -1778,6 +1919,231 @@ pub mod texts { } } + pub fn tui_hermes_models_summary(total: usize) -> String { + if is_chinese() { + if total == 0 { + "未配置模型".to_string() + } else { + format!("已配置 {total} 个模型") + } + } else if total == 0 { + "No models configured".to_string() + } else { + format!("{total} models configured") + } + } + + pub fn tui_hermes_models_open_hint() -> &'static str { + if is_chinese() { + "Enter 编辑模型列表" + } else { + "Enter to edit models" + } + } + + pub fn tui_hermes_models_title(provider_name: &str) -> String { + let name = provider_name.trim(); + if is_chinese() { + if name.is_empty() { + "Hermes 模型列表".to_string() + } else { + format!("Hermes 模型列表: {name}") + } + } else if name.is_empty() { + "Hermes Models".to_string() + } else { + format!("Hermes Models: {name}") + } + } + + pub fn tui_hermes_models_no_models() -> &'static str { + if is_chinese() { + "暂无模型配置。切换到此供应商时将不会更新默认模型。" + } else { + "No models configured. Switching to this provider won't change the default model." + } + } + + pub fn tui_hermes_models_hint() -> &'static str { + if is_chinese() { + "切换到此供应商时,第一个模型会写入顶层 model.default。" + } else { + "On switch, the first model is written to top-level model.default." + } + } + + pub fn tui_hermes_model_id_label(index: usize) -> String { + if is_chinese() { + if index == 1 { + format!("模型 {index} ID(默认模型)") + } else { + format!("模型 {index} ID(备选模型)") + } + } else if index == 1 { + format!("Model {index} ID (Default)") + } else { + format!("Model {index} ID (Alternate)") + } + } + + pub fn tui_hermes_model_name_label(index: usize) -> String { + if is_chinese() { + format!("模型 {index} 显示名称") + } else { + format!("Model {index} Display Name") + } + } + + pub fn tui_hermes_model_context_length_label(index: usize) -> String { + if is_chinese() { + format!("模型 {index} 上下文长度") + } else { + format!("Model {index} Context Length") + } + } + + pub fn tui_hermes_models_fetch_hint() -> &'static str { + if is_chinese() { + "获取模型列表后,可在当前模型 ID 行选择模型" + } else { + "Fetch models, then select a model for the current model ID row" + } + } + + pub fn tui_hermes_models_add_hint() -> &'static str { + if is_chinese() { + "添加一个空模型行" + } else { + "Add an empty model row" + } + } + + pub fn tui_model_fetch_need_config() -> &'static str { + if is_chinese() { + "请先填写 API 端点和 API Key" + } else { + "Please fill in API endpoint and API Key first" + } + } + + pub fn tui_model_fetch_need_api_key() -> &'static str { + if is_chinese() { + "请先填写 API Key" + } else { + "Please fill in API Key first" + } + } + + pub fn tui_model_fetch_need_endpoint() -> &'static str { + if is_chinese() { + "请先填写 API 端点" + } else { + "Please fill in API endpoint first" + } + } + + pub fn tui_hermes_memory_title() -> &'static str { + if is_chinese() { + "Hermes 记忆管理" + } else { + "Hermes Memory" + } + } + + pub fn tui_hermes_memory_agent_tab() -> &'static str { + if is_chinese() { + "Agent 记忆" + } else { + "Agent Memory" + } + } + + pub fn tui_hermes_memory_user_tab() -> &'static str { + if is_chinese() { + "用户记忆" + } else { + "User Memory" + } + } + + pub fn tui_hermes_memory_directory_label() -> &'static str { + if is_chinese() { + "记忆目录" + } else { + "Memory directory" + } + } + + pub fn tui_hermes_memory_file_label() -> &'static str { + if is_chinese() { + "文件" + } else { + "File" + } + } + + pub fn tui_hermes_memory_status_label() -> &'static str { + if is_chinese() { + "状态" + } else { + "Status" + } + } + + pub fn tui_hermes_memory_usage_label() -> &'static str { + if is_chinese() { + "用量" + } else { + "Usage" + } + } + + pub fn tui_hermes_memory_preview_label() -> &'static str { + if is_chinese() { + "预览" + } else { + "Preview" + } + } + + pub fn tui_hermes_memory_editor_title(label: &str) -> String { + if is_chinese() { + format!("编辑 {label}") + } else { + format!("Edit {label}") + } + } + + pub fn tui_hermes_memory_saved(label: &str) -> String { + if is_chinese() { + format!("已保存 {label}") + } else { + format!("Saved {label}") + } + } + + pub fn tui_hermes_memory_toggle_saved(label: &str, enabled: bool) -> String { + if is_chinese() { + if enabled { + format!("已启用 {label}") + } else { + format!("已禁用 {label}") + } + } else if enabled { + format!("Enabled {label}") + } else { + format!("Disabled {label}") + } + } + + pub fn tui_hermes_memory_directory_open_failed(detail: &str) -> String { + if is_chinese() { + format!("打开记忆目录失败: {detail}") + } else { + format!("Failed to open memory directory: {detail}") + } + } + pub fn tui_toast_json_must_be_array() -> &'static str { if is_chinese() { "JSON 必须是数组" @@ -1786,6 +2152,14 @@ pub mod texts { } } + pub fn tui_toast_json_must_be_object_or_array() -> &'static str { + if is_chinese() { + "JSON 必须是对象或数组" + } else { + "JSON must be an object or array" + } + } + pub fn tui_label_opencode_model_id() -> &'static str { if is_chinese() { "主模型 ID" @@ -1928,6 +2302,14 @@ pub mod texts { } } + pub fn tui_label_app_hermes() -> &'static str { + if is_chinese() { + "应用: Hermes" + } else { + "App: Hermes" + } + } + pub fn tui_form_templates_title() -> &'static str { if is_chinese() { "模板" @@ -2592,6 +2974,14 @@ pub mod texts { } } + pub fn tui_key_enable() -> &'static str { + if is_chinese() { + "启用" + } else { + "enable" + } + } + pub fn tui_key_edit() -> &'static str { if is_chinese() { "编辑" @@ -3602,14 +3992,15 @@ pub mod texts { codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -3619,14 +4010,15 @@ pub mod texts { codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -4785,6 +5177,14 @@ pub mod texts { } } + pub fn tui_toast_provider_managed_by_hermes() -> &'static str { + if is_chinese() { + "该供应商由 Hermes 管理,请在 Hermes Web UI 中编辑。" + } else { + "This provider is managed by Hermes. Edit it in the Hermes Web UI." + } + } + pub fn tui_toast_provider_cannot_remove_default_model() -> &'static str { if is_chinese() { "被当前默认模型引用的供应商不能直接从配置中移除。" @@ -4841,6 +5241,14 @@ pub mod texts { } } + pub fn tui_toast_provider_enabled(provider: &str) -> String { + if is_chinese() { + format!("已启用供应商: {}", provider) + } else { + format!("Provider enabled: {}", provider) + } + } + pub fn tui_temp_launch_failed(message: &str) -> String { if is_chinese() { format!("临时启动失败: {}", message) @@ -4865,6 +5273,26 @@ pub mod texts { } } + pub fn tui_confirm_remove_provider_title() -> &'static str { + if is_chinese() { + "移除供应商" + } else { + "Remove Provider" + } + } + + pub fn tui_confirm_remove_provider_message(name: &str) -> String { + if is_chinese() { + format!( + "确定要从配置中移除供应商 \"{name}\" 吗?\n\n移除后该供应商将不再生效,但配置数据会保留在 CC Switch 中,您可以随时重新添加。" + ) + } else { + format!( + "Are you sure you want to remove provider \"{name}\" from the configuration?\n\nAfter removal, this provider will no longer be active, but the configuration data will be retained in CC Switch. You can re-add it at any time." + ) + } + } + pub fn tui_mcp_add_title() -> &'static str { if is_chinese() { "新增 MCP 服务器" @@ -6404,6 +6832,19 @@ pub mod texts { ("🤖 Agents Config", "🤖 Agents 配置") } + pub fn menu_hermes_memory() -> &'static str { + let (en, zh) = menu_hermes_memory_variants(); + if is_chinese() { + zh + } else { + en + } + } + + pub fn menu_hermes_memory_variants() -> (&'static str, &'static str) { + ("🧠 Memory", "🧠 记忆管理") + } + pub fn menu_settings() -> &'static str { let (en, zh) = menu_settings_variants(); if is_chinese() { diff --git a/src-tauri/src/cli/i18n/texts/config_actions.rs b/src-tauri/src/cli/i18n/texts/config_actions.rs index 1ae3e7e3..28c165d1 100644 --- a/src-tauri/src/cli/i18n/texts/config_actions.rs +++ b/src-tauri/src/cli/i18n/texts/config_actions.rs @@ -315,14 +315,15 @@ pub fn tui_skills_installed_counts( codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -332,14 +333,15 @@ pub fn tui_mcp_server_counts( codex: usize, gemini: usize, opencode: usize, + hermes: usize, ) -> String { if is_chinese() { format!( - "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "已安装 · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } else { format!( - "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode}" + "Installed · Claude: {claude} · Codex: {codex} · Gemini: {gemini} · OpenCode: {opencode} · Hermes: {hermes}" ) } } @@ -704,6 +706,14 @@ pub fn tui_toast_provider_cannot_delete_current() -> &'static str { } } +pub fn tui_toast_provider_managed_by_hermes() -> &'static str { + if is_chinese() { + "该供应商由 Hermes 管理,请在 Hermes Web UI 中编辑。" + } else { + "This provider is managed by Hermes. Edit it in the Hermes Web UI." + } +} + pub fn tui_confirm_delete_provider_title() -> &'static str { if is_chinese() { "删除供应商" @@ -898,9 +908,7 @@ pub fn tui_confirm_import_prompt_title() -> &'static str { pub fn tui_confirm_import_prompt_message(filename: &str) -> String { if is_chinese() { - format!( - "当前提示词列表为空,检测到已有 {filename}。是否把它作为新提示词打开编辑?" - ) + format!("当前提示词列表为空,检测到已有 {filename}。是否把它作为新提示词打开编辑?") } else { format!( "The prompt list is empty and {filename} already exists. Open it as a new editable prompt?" diff --git a/src-tauri/src/cli/i18n/texts/providers.rs b/src-tauri/src/cli/i18n/texts/providers.rs index aa99b09f..8b0d6348 100644 --- a/src-tauri/src/cli/i18n/texts/providers.rs +++ b/src-tauri/src/cli/i18n/texts/providers.rs @@ -178,6 +178,14 @@ pub fn tui_label_app_opencode() -> &'static str { } } +pub fn tui_label_app_hermes() -> &'static str { + if is_chinese() { + "应用: Hermes" + } else { + "App: Hermes" + } +} + pub fn tui_form_templates_title() -> &'static str { if is_chinese() { "模板" diff --git a/src-tauri/src/cli/mod.rs b/src-tauri/src/cli/mod.rs index 2de4dd3f..2f4eb7f7 100644 --- a/src-tauri/src/cli/mod.rs +++ b/src-tauri/src/cli/mod.rs @@ -65,6 +65,10 @@ pub enum Commands { #[command(subcommand)] Failover(commands::failover::FailoverCommand), + /// Hermes-specific commands (memory blobs etc.) + #[command(subcommand)] + Hermes(commands::hermes::HermesCommand), + /// Start an app with a provider selector without switching the global current provider #[cfg(unix)] #[command(subcommand)] diff --git a/src-tauri/src/cli/tui/app.rs b/src-tauri/src/cli/tui/app.rs index f12e40b6..f146c3e4 100644 --- a/src-tauri/src/cli/tui/app.rs +++ b/src-tauri/src/cli/tui/app.rs @@ -36,6 +36,7 @@ pub(crate) use app_state::{ Action, App, ConfigItem, LocalProxySettingsItem, MoveDirection, ProxyVisualTransition, SettingsItem, WebDavConfigItem, PROXY_HERO_TRANSITION_TICKS, }; +pub(crate) use content_config::HERMES_MEMORY_ROW_COUNT; pub use editor_state::{EditorKind, EditorMode, EditorState, EditorSubmit}; pub(crate) use helpers::*; pub use types::{ diff --git a/src-tauri/src/cli/tui/app/app_state.rs b/src-tauri/src/cli/tui/app/app_state.rs index 321a9b3c..7011a335 100644 --- a/src-tauri/src/cli/tui/app/app_state.rs +++ b/src-tauri/src/cli/tui/app/app_state.rs @@ -173,6 +173,14 @@ pub enum Action { OpenClawOpenDirectory { subdir: String, }, + HermesMemoryOpen { + kind: crate::hermes_config::MemoryKind, + }, + HermesMemorySetEnabled { + kind: crate::hermes_config::MemoryKind, + enabled: bool, + }, + HermesOpenMemoryDirectory, ConfigReset, EditorSubmit { @@ -501,6 +509,7 @@ pub struct App { pub config_idx: usize, pub workspace_idx: usize, pub daily_memory_idx: usize, + pub hermes_memory_idx: usize, pub openclaw_tools_form: Option, pub openclaw_agents_form: Option, pub openclaw_daily_memory_search_query: String, diff --git a/src-tauri/src/cli/tui/app/content_config.rs b/src-tauri/src/cli/tui/app/content_config.rs index ecc78a16..ec53e963 100644 --- a/src-tauri/src/cli/tui/app/content_config.rs +++ b/src-tauri/src/cli/tui/app/content_config.rs @@ -1,5 +1,7 @@ use super::*; +pub(crate) const HERMES_MEMORY_ROW_COUNT: usize = 2; + impl App { fn open_openclaw_editor( &mut self, @@ -232,6 +234,28 @@ impl App { } } + pub(crate) fn on_hermes_memory_key(&mut self, key: KeyEvent, data: &UiData) -> Action { + let selected = hermes_memory_kind_for_index(self.hermes_memory_idx); + match key.code { + KeyCode::Up => { + self.hermes_memory_idx = self.hermes_memory_idx.saturating_sub(1); + Action::None + } + KeyCode::Down => { + self.hermes_memory_idx = + (self.hermes_memory_idx + 1).min(HERMES_MEMORY_ROW_COUNT - 1); + Action::None + } + KeyCode::Enter | KeyCode::Char('e') => Action::HermesMemoryOpen { kind: selected }, + KeyCode::Char(' ') | KeyCode::Char('x') => Action::HermesMemorySetEnabled { + kind: selected, + enabled: !data.config.hermes_memory.enabled(selected), + }, + KeyCode::Char('o') => Action::HermesOpenMemoryDirectory, + _ => Action::None, + } + } + pub(crate) fn on_config_openclaw_env_key(&mut self, key: KeyEvent, data: &UiData) -> Action { match key.code { KeyCode::Enter | KeyCode::Char('e') => { @@ -1159,3 +1183,10 @@ impl App { self.focus = Focus::Content; } } + +pub(crate) fn hermes_memory_kind_for_index(index: usize) -> crate::hermes_config::MemoryKind { + match index.min(HERMES_MEMORY_ROW_COUNT - 1) { + 1 => crate::hermes_config::MemoryKind::User, + _ => crate::hermes_config::MemoryKind::Memory, + } +} diff --git a/src-tauri/src/cli/tui/app/content_entities.rs b/src-tauri/src/cli/tui/app/content_entities.rs index 0fccc831..d6962499 100644 --- a/src-tauri/src/cli/tui/app/content_entities.rs +++ b/src-tauri/src/cli/tui/app/content_entities.rs @@ -1,24 +1,56 @@ use super::*; impl App { - fn provider_switch_action(&mut self, row: &super::data::ProviderRow) -> Action { - if matches!(self.app_type, AppType::OpenCode) { - if row.is_in_config { - return Action::ProviderRemoveFromConfig { id: row.id.clone() }; - } + fn is_provider_read_only(&self, row: &super::data::ProviderRow) -> bool { + super::data::provider_is_read_only(&self.app_type, row) + } - return Action::ProviderSwitch { id: row.id.clone() }; - } - if matches!(self.app_type, AppType::OpenClaw) { + fn can_delete_provider(&self, row: &super::data::ProviderRow) -> bool { + !self.is_provider_read_only(row) && (self.app_type.is_additive_mode() || !row.is_current) + } + + fn show_provider_read_only_toast(&mut self) { + self.push_toast( + texts::tui_toast_provider_managed_by_hermes(), + ToastKind::Info, + ); + } + + fn open_provider_delete_confirm(&mut self, row: &super::data::ProviderRow) { + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: texts::tui_confirm_delete_provider_title().to_string(), + message: texts::tui_confirm_delete_provider_message( + &super::data::provider_display_name(&self.app_type, row), + &row.id, + ), + action: ConfirmAction::ProviderDelete { id: row.id.clone() }, + }); + } + + fn open_provider_remove_confirm(&mut self, row: &super::data::ProviderRow) { + self.overlay = Overlay::Confirm(ConfirmOverlay { + title: texts::tui_confirm_remove_provider_title().to_string(), + message: texts::tui_confirm_remove_provider_message( + &super::data::provider_display_name(&self.app_type, row), + ), + action: ConfirmAction::ProviderRemoveFromConfig { id: row.id.clone() }, + }); + } + + fn provider_switch_action(&mut self, row: &super::data::ProviderRow) -> Action { + if self.app_type.is_additive_mode() { if row.is_in_config { - if row.is_default_model { + if matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) + && row.is_default_model + { self.push_toast( texts::tui_toast_provider_cannot_remove_default_model(), ToastKind::Warning, ); return Action::None; } - return Action::ProviderRemoveFromConfig { id: row.id.clone() }; + self.open_provider_remove_confirm(row); + return Action::None; } return Action::ProviderSwitch { id: row.id.clone() }; @@ -60,6 +92,31 @@ impl App { }; } + fn provider_set_default_action(&mut self, row: &super::data::ProviderRow) -> Action { + if !matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) { + return Action::None; + } + if !row.is_in_config { + self.push_toast( + texts::tui_toast_provider_default_requires_live_config(), + ToastKind::Warning, + ); + return Action::None; + } + let model_id = row.primary_model_id.clone().unwrap_or_default(); + if matches!(self.app_type, AppType::OpenClaw) && model_id.is_empty() { + self.push_toast( + texts::tui_toast_provider_default_model_missing(), + ToastKind::Warning, + ); + return Action::None; + } + Action::ProviderSetDefaultModel { + provider_id: row.id.clone(), + model_id, + } + } + pub(crate) fn on_providers_key(&mut self, key: KeyEvent, data: &UiData) -> Action { let visible = visible_providers(&self.app_type, &self.filter, data); match key.code { @@ -90,6 +147,10 @@ impl App { let Some(row) = visible.get(self.provider_idx) else { return Action::None; }; + if self.is_provider_read_only(row) { + self.show_provider_read_only_toast(); + return Action::None; + } self.open_provider_edit_form(row, data); Action::None } @@ -103,47 +164,24 @@ impl App { let Some(row) = visible.get(self.provider_idx) else { return Action::None; }; - if !matches!(self.app_type, AppType::OpenClaw) { - return Action::None; - } - if !row.is_in_config { - self.push_toast( - texts::tui_toast_provider_default_requires_live_config(), - ToastKind::Warning, - ); - return Action::None; - } - let Some(model_id) = row.primary_model_id.clone() else { - self.push_toast( - texts::tui_toast_provider_default_model_missing(), - ToastKind::Warning, - ); - return Action::None; - }; - Action::ProviderSetDefaultModel { - provider_id: row.id.clone(), - model_id, - } + self.provider_set_default_action(row) } KeyCode::Char('d') => { let Some(row) = visible.get(self.provider_idx) else { return Action::None; }; - if row.is_current { + if self.is_provider_read_only(row) { + self.show_provider_read_only_toast(); + return Action::None; + } + if !self.can_delete_provider(row) { self.push_toast( texts::tui_toast_provider_cannot_delete_current(), ToastKind::Warning, ); return Action::None; } - self.overlay = Overlay::Confirm(ConfirmOverlay { - title: texts::tui_confirm_delete_provider_title().to_string(), - message: texts::tui_confirm_delete_provider_message( - &super::data::provider_display_name(&self.app_type, row), - &row.id, - ), - action: ConfirmAction::ProviderDelete { id: row.id.clone() }, - }); + self.open_provider_delete_confirm(row); Action::None } KeyCode::Char('t') => { @@ -204,34 +242,16 @@ impl App { match key.code { KeyCode::Char('e') => { + if self.is_provider_read_only(row) { + self.show_provider_read_only_toast(); + return Action::None; + } self.open_provider_edit_form(row, data); Action::None } KeyCode::Enter => Action::None, KeyCode::Char('s') | KeyCode::Char(' ') => self.provider_switch_action(row), - KeyCode::Char('x') => { - if !matches!(self.app_type, AppType::OpenClaw) { - return Action::None; - } - if !row.is_in_config { - self.push_toast( - texts::tui_toast_provider_default_requires_live_config(), - ToastKind::Warning, - ); - return Action::None; - } - let Some(model_id) = row.primary_model_id.clone() else { - self.push_toast( - texts::tui_toast_provider_default_model_missing(), - ToastKind::Warning, - ); - return Action::None; - }; - Action::ProviderSetDefaultModel { - provider_id: row.id.clone(), - model_id, - } - } + KeyCode::Char('x') => self.provider_set_default_action(row), KeyCode::Char('t') => { self.open_provider_test_menu(row); Action::None diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index 4e9ed3ab..26846500 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -40,6 +40,9 @@ pub enum EditorSubmit { OpenClawDailyMemoryFile { filename: String, }, + HermesMemory { + kind: crate::hermes_config::MemoryKind, + }, ConfigOpenClawEnv, ConfigOpenClawTools, ConfigOpenClawAgents, diff --git a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs index 890ca444..5c3e11dd 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/mcp.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/mcp.rs @@ -146,6 +146,7 @@ impl App { McpAddField::AppCodex => mcp.apps.codex = !mcp.apps.codex, McpAddField::AppGemini => mcp.apps.gemini = !mcp.apps.gemini, McpAddField::AppOpenCode => mcp.apps.opencode = !mcp.apps.opencode, + McpAddField::AppHermes => mcp.apps.hermes = !mcp.apps.hermes, _ => { if selected == McpAddField::Id && mcp.locked_id().is_some() { return Some(Action::None); diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index be858d84..79975173 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -1,4 +1,5 @@ use super::*; +use url::Url; impl App { pub(super) fn handle_provider_template_key( @@ -70,6 +71,20 @@ impl App { } else { texts::tui_toast_provider_add_missing_fields() }) + } else if matches!(provider.app_type, crate::app_config::AppType::Hermes) + && provider.id.is_blank() + { + Some(texts::tui_toast_provider_add_missing_fields()) + } else if matches!(provider.app_type, crate::app_config::AppType::Hermes) + && !is_valid_hermes_provider_key(provider.id.value.trim()) + { + Some(texts::tui_hermes_provider_key_invalid()) + } else if matches!(provider.app_type, crate::app_config::AppType::Hermes) + && !is_valid_hermes_rate_limit_delay(&provider.hermes_rate_limit_delay.value) + { + Some(texts::tui_hermes_rate_limit_delay_invalid()) + } else if matches!(provider.app_type, crate::app_config::AppType::Hermes) { + validate_hermes_base_url(&provider.hermes_base_url.value) } else if matches!(provider.app_type, crate::app_config::AppType::Codex) && !provider.is_codex_official_provider() && provider.codex_base_url.is_blank() @@ -145,6 +160,7 @@ impl App { let policy = TextInputPolicy { max_chars: (selected == ProviderAddField::Notes) .then_some(PROVIDER_NOTES_MAX_CHARS), + sanitize: provider_field_sanitize_fn(&provider.app_type, selected), }; let changed = provider .input_mut(selected) @@ -269,6 +285,13 @@ impl App { provider.openclaw_user_agent = !provider.openclaw_user_agent; Action::None } + ProviderAddField::HermesApiMode => { + let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { + return Action::None; + }; + provider.cycle_hermes_api_mode(); + Action::None + } ProviderAddField::ClaudeModelConfig => { self.overlay = Overlay::ClaudeModelPicker { selected: 0, @@ -300,6 +323,16 @@ impl App { } Action::None } + ProviderAddField::HermesModels => { + if matches!(key.code, KeyCode::Enter) { + let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { + return Action::None; + }; + provider.open_hermes_models_picker(); + self.overlay = Overlay::HermesModelsPicker { editing: false }; + } + Action::None + } ProviderAddField::CommonSnippet => { if matches!(key.code, KeyCode::Enter) { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { @@ -538,6 +571,31 @@ impl App { Action::None } + pub(crate) fn build_hermes_models_fetch_action(&mut self) -> Action { + let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { + return Action::None; + }; + let base_url = provider.hermes_base_url.value.trim(); + let api_key = provider.hermes_api_key.value.trim(); + let missing_message = match (base_url.is_empty(), api_key.is_empty()) { + (true, true) => Some(texts::tui_model_fetch_need_config()), + (true, false) => Some(texts::tui_model_fetch_need_endpoint()), + (false, true) => Some(texts::tui_model_fetch_need_api_key()), + (false, false) => None, + }; + if let Some(message) = missing_message { + self.push_toast(message, ToastKind::Warning); + return Action::None; + } + + Action::ProviderModelFetch { + base_url: provider.hermes_base_url.value.clone(), + api_key: Some(provider.hermes_api_key.value.clone()), + field: ProviderAddField::HermesModels, + claude_idx: None, + } + } + fn handle_provider_model_field_activate( &mut self, selected: ProviderAddField, @@ -557,12 +615,17 @@ impl App { (!provider.opencode_api_key.value.trim().is_empty()) .then(|| provider.opencode_api_key.value.clone()) } + ProviderAddField::HermesModels => { + (!provider.hermes_api_key.value.trim().is_empty()) + .then(|| provider.hermes_api_key.value.clone()) + } _ => None, }; let base_url = match selected { ProviderAddField::CodexModel => provider.codex_base_url.value.clone(), ProviderAddField::GeminiModel => provider.gemini_base_url.value.clone(), ProviderAddField::OpenCodeModelId => provider.opencode_base_url.value.clone(), + ProviderAddField::HermesModels => provider.hermes_base_url.value.clone(), _ => String::new(), }; Action::ProviderModelFetch { @@ -829,15 +892,109 @@ fn usage_query_provider_credential_field(field: ProviderAddField) -> bool { | ProviderAddField::CodexBaseUrl | ProviderAddField::GeminiApiKey | ProviderAddField::GeminiBaseUrl + | ProviderAddField::HermesApiKey + | ProviderAddField::HermesBaseUrl | ProviderAddField::OpenCodeApiKey | ProviderAddField::OpenCodeBaseUrl ) } +fn sanitize_hermes_provider_key_char(ch: char) -> Option { + let ch = ch.to_ascii_lowercase(); + (ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-').then_some(ch) +} + +fn sanitize_number_char(ch: char) -> Option { + (ch.is_ascii_digit() || ch == '.').then_some(ch) +} + +fn provider_field_sanitize_fn( + app_type: &AppType, + selected: ProviderAddField, +) -> Option Option> { + match (app_type, selected) { + (&AppType::Hermes, ProviderAddField::Id) => Some(sanitize_hermes_provider_key_char), + (&AppType::Hermes, ProviderAddField::HermesRateLimitDelay) => Some(sanitize_number_char), + _ => None, + } +} + +fn is_valid_hermes_provider_key(value: &str) -> bool { + let mut previous_dash = false; + let mut saw_char = false; + for ch in value.chars() { + let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-'; + if !valid { + return false; + } + if ch == '-' { + if !saw_char || previous_dash { + return false; + } + previous_dash = true; + } else { + saw_char = true; + previous_dash = false; + } + } + saw_char && !previous_dash +} + +fn is_valid_hermes_rate_limit_delay(value: &str) -> bool { + let trimmed = value.trim(); + if trimmed.is_empty() { + return true; + } + trimmed + .parse::() + .is_ok_and(|value| value.is_finite() && value >= 0.0) +} + +fn validate_hermes_base_url(raw: &str) -> Option<&'static str> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Some(texts::tui_hermes_base_url_required()); + } + + let candidate = replace_hermes_template_tokens(trimmed); + let Ok(url) = Url::parse(&candidate) else { + return Some(texts::tui_hermes_base_url_invalid()); + }; + if !matches!(url.scheme(), "http" | "https") { + return Some(texts::tui_hermes_base_url_scheme()); + } + if url.host_str().is_none_or(str::is_empty) { + return Some(texts::tui_hermes_base_url_invalid()); + } + None +} + +fn replace_hermes_template_tokens(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut rest = raw; + while let Some(start) = rest.find("${") { + let (before, after_start) = rest.split_at(start); + out.push_str(before); + if let Some(end) = after_start.find('}') { + out.push_str("placeholder"); + rest = &after_start[end + 1..]; + } else { + out.push_str(after_start); + rest = ""; + } + } + out.push_str(rest); + out +} + fn is_provider_divider_field(field: Option<&ProviderAddField>) -> bool { matches!( field, - Some(ProviderAddField::CommonConfigDivider | ProviderAddField::UsageQueryDivider) + Some( + ProviderAddField::HermesAdvancedDivider + | ProviderAddField::CommonConfigDivider + | ProviderAddField::UsageQueryDivider + ) ) } diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index 0c8738c7..41b49af1 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -895,6 +895,7 @@ pub(crate) fn route_has_content_list(route: &Route) -> bool { | Route::ProviderDetail { .. } | Route::Mcp | Route::Prompts + | Route::HermesMemory | Route::Config | Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory @@ -1185,12 +1186,13 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Codex => 1, AppType::Gemini => 2, AppType::OpenCode => 3, - AppType::OpenClaw => 4, + AppType::Hermes => 4, + AppType::OpenClaw => 5, } } pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize { - app_type_picker_index(app_type).min(3) + app_type_picker_index(app_type).min(4) } pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { @@ -1198,7 +1200,8 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { 1 => AppType::Codex, 2 => AppType::Gemini, 3 => AppType::OpenCode, - 4 => AppType::OpenClaw, + 4 => AppType::Hermes, + 5 => AppType::OpenClaw, _ => AppType::Claude, } } diff --git a/src-tauri/src/cli/tui/app/menu.rs b/src-tauri/src/cli/tui/app/menu.rs index 2ac66e93..298eebc1 100644 --- a/src-tauri/src/cli/tui/app/menu.rs +++ b/src-tauri/src/cli/tui/app/menu.rs @@ -56,6 +56,7 @@ impl App { config_idx: 0, workspace_idx: 0, daily_memory_idx: 0, + hermes_memory_idx: 0, openclaw_tools_form: None, openclaw_agents_form: None, openclaw_daily_memory_search_query: String::new(), @@ -85,6 +86,7 @@ impl App { Route::Providers | Route::ProviderDetail { .. } => NavItem::Providers, Route::Mcp => NavItem::Mcp, Route::Prompts => NavItem::Prompts, + Route::HermesMemory => NavItem::HermesMemory, Route::Config => NavItem::Config, Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { if matches!(app_type, AppType::OpenClaw) { @@ -341,6 +343,15 @@ impl App { return Action::Quit; } + if key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char(',')) + && !self.overlay.is_active() + && self.editor.is_none() + && self.form.is_none() + { + return self.push_route_and_switch(Route::Settings); + } + let key = self.normalize_vim_navigation_key(key); if self.overlay.is_active() { @@ -373,6 +384,10 @@ impl App { self.filter.active = true; return Action::None; } + KeyCode::Char('f') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.filter.active = true; + return Action::None; + } KeyCode::Char('[') => { return cycle_app_type(&self.app_type, -1) .map(Action::SetAppType) @@ -496,6 +511,7 @@ impl App { Route::ProviderDetail { id } => self.on_provider_detail_key(key, data, &id), Route::Mcp => self.on_mcp_key(key, data), Route::Prompts => self.on_prompts_key(key, data), + Route::HermesMemory => self.on_hermes_memory_key(key, data), Route::Config => self.on_config_key(key, data), Route::ConfigOpenClawWorkspace => self.on_config_openclaw_workspace_key(key, data), Route::ConfigOpenClawDailyMemory => self.on_config_openclaw_daily_memory_key(key, data), @@ -589,6 +605,13 @@ impl App { self.daily_memory_idx = self.daily_memory_idx.min(daily_memory_len - 1); } + let hermes_memory_len = crate::cli::tui::app::HERMES_MEMORY_ROW_COUNT; + if hermes_memory_len == 0 { + self.hermes_memory_idx = 0; + } else { + self.hermes_memory_idx = self.hermes_memory_idx.min(hermes_memory_len - 1); + } + let config_webdav_len = visible_webdav_config_items(&self.filter).len(); if config_webdav_len == 0 { self.config_webdav_idx = 0; diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs index 0d15a2d1..9255b946 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/dialogs.rs @@ -30,6 +30,9 @@ impl App { ConfirmAction::ProviderDelete { id } => { Action::ProviderDelete { id: id.clone() } } + ConfirmAction::ProviderRemoveFromConfig { id } => { + Action::ProviderRemoveFromConfig { id: id.clone() } + } ConfirmAction::McpDelete { id } => Action::McpDelete { id: id.clone() }, ConfirmAction::PromptDelete { id } => Action::PromptDelete { id: id.clone() }, ConfirmAction::SkillsUninstall { directory } => Action::SkillsUninstall { diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index d20658ed..d786d952 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -15,6 +15,9 @@ impl App { if let Some(action) = self.handle_usage_query_template_picker_key(key) { return Some(action); } + if let Some(action) = self.handle_hermes_models_picker_key(key) { + return Some(action); + } if let Some(action) = self.handle_provider_test_menu_key(key, data) { return Some(action); } @@ -51,6 +54,108 @@ impl App { None } + fn handle_hermes_models_picker_key(&mut self, key: KeyEvent) -> Option { + let editing = match self.overlay { + Overlay::HermesModelsPicker { editing } => editing, + _ => return None, + }; + + if editing { + return Some(self.handle_hermes_models_picker_editing_key(key)); + } + + Some(self.handle_hermes_models_picker_navigation_key(key)) + } + + fn handle_hermes_models_picker_editing_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + provider.hermes_models_editing = false; + } + self.overlay = Overlay::HermesModelsPicker { editing: false }; + Action::None + } + _ => { + if TextEditCommand::from_key(key).is_none() { + return Action::None; + } + let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { + return Action::None; + }; + let Some(selected) = provider.selected_hermes_model_field() else { + return Action::None; + }; + if provider + .hermes_model_input + .apply_key(key) + .is_some_and(|edit| edit.changed) + { + let value = provider.hermes_model_input.value.clone(); + provider.set_hermes_model_field_text(selected, &value); + } + Action::None + } + } + } + + fn handle_hermes_models_picker_navigation_key(&mut self, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + provider.close_hermes_models_picker(); + } + self.overlay = Overlay::None; + Action::None + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + provider.hermes_models_field_idx = + provider.hermes_models_field_idx.saturating_sub(1); + provider.sync_hermes_model_input_from_selection(); + } + Action::None + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + let fields_len = provider.hermes_model_fields().len(); + if fields_len > 0 { + provider.hermes_models_field_idx = + (provider.hermes_models_field_idx + 1).min(fields_len - 1); + } else { + provider.hermes_models_field_idx = 0; + } + provider.sync_hermes_model_input_from_selection(); + } + Action::None + } + KeyCode::Char('a') | KeyCode::Char('A') => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + provider.add_empty_hermes_model(); + } + Action::None + } + KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Delete | KeyCode::Backspace => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + provider.remove_selected_hermes_model(); + } + Action::None + } + KeyCode::Char('f') | KeyCode::Char('F') => self.build_hermes_models_fetch_action(), + KeyCode::Enter | KeyCode::Char(' ') => { + if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { + if provider.selected_hermes_model_field().is_some() { + provider.sync_hermes_model_input_from_selection(); + provider.hermes_models_editing = true; + self.overlay = Overlay::HermesModelsPicker { editing: true }; + } + } + Action::None + } + _ => Action::None, + } + } + fn handle_sync_method_picker_key(&mut self, key: KeyEvent, data: &UiData) -> Option { let Overlay::SkillsSyncMethodPicker { selected } = &mut self.overlay else { return None; @@ -58,7 +163,7 @@ impl App { Some(match key.code { KeyCode::Esc => { - self.overlay = Overlay::None; + self.close_overlay(); Action::None } KeyCode::Up => { @@ -66,7 +171,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(4); Action::None } KeyCode::Enter => { @@ -409,7 +514,7 @@ impl App { let field = *field; let claude_idx = *claude_idx; - self.overlay = Overlay::None; + self.close_overlay(); if let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() { if field == ProviderAddField::ClaudeModelConfig { @@ -419,6 +524,8 @@ impl App { provider.mark_claude_model_config_touched(); } } + } else if field == ProviderAddField::HermesModels { + provider.set_selected_hermes_model_id_from_picker(&selected_model); } else if let Some(input_field) = provider.input_mut(field) { input_field.set(selected_model); } @@ -574,7 +681,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(4); Action::None } KeyCode::Char(' ') => { @@ -707,7 +814,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(4); Action::None } KeyCode::Char(' ') => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 935d424b..b50e6638 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -133,6 +133,7 @@ mod tests { editing: false, } .is_editing()); + assert!(!Overlay::HermesModelsPicker { editing: false }.is_editing()); assert!(Overlay::TextInput(TextInputState { title: "Title".to_string(), @@ -147,6 +148,7 @@ mod tests { editing: true, } .is_editing()); + assert!(Overlay::HermesModelsPicker { editing: true }.is_editing()); assert!(Overlay::ModelFetchPicker { request_id: 1, field: ProviderAddField::Name, @@ -199,6 +201,19 @@ mod tests { app } + fn assert_provider_remove_confirm(app: &App, expected_id: &str, expected_name: &str) { + assert!(matches!( + &app.overlay, + Overlay::Confirm(ConfirmOverlay { + title, + message, + action: ConfirmAction::ProviderRemoveFromConfig { id }, + }) if id == expected_id + && title == texts::tui_confirm_remove_provider_title() + && message == &texts::tui_confirm_remove_provider_message(expected_name) + )); + } + fn open_prompt_fields_form() -> App { let mut app = App::new(Some(AppType::Claude)); app.route = Route::Prompts; @@ -624,7 +639,44 @@ mod tests { } #[test] - fn skills_apps_picker_from_openclaw_targets_opencode_last_visible_row() { + fn skills_apps_picker_can_select_hermes() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Skills; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.skills + .installed + .push(installed_skill("hello-skill", "Hello Skill")); + + app.on_key(key(KeyCode::Char('m')), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + + let action = app.on_key(key(KeyCode::Char(' ')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!( + &app.overlay, + Overlay::SkillsAppsPicker { selected, apps, .. } if *selected == 4 && apps.hermes + )); + + let action = app.on_key(key(KeyCode::Enter), &data); + assert!(matches!( + action, + Action::SkillsSetApps { directory, apps } + if directory == "hello-skill" + && !apps.claude + && !apps.codex + && !apps.gemini + && !apps.opencode + && apps.hermes + )); + } + + #[test] + fn skills_apps_picker_from_openclaw_targets_hermes_last_visible_row() { let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::Skills; app.focus = Focus::Content; @@ -638,7 +690,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::SkillsAppsPicker { selected, .. } if *selected == 3 + Overlay::SkillsAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char(' ')), &data); @@ -646,11 +698,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::SkillsAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -792,6 +845,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -816,6 +870,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -856,6 +911,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -878,6 +934,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -904,6 +961,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -926,6 +984,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -1272,162 +1331,522 @@ mod tests { } #[test] - fn multiline_editor_supports_readline_shortcuts() { - let mut app = App::new(Some(AppType::Claude)); - app.open_editor( - "Prompt", - EditorKind::Plain, - "first line\nalpha beta", - EditorSubmit::PromptCreate { - id: "demo".to_string(), - name: "Demo".to_string(), - description: None, - }, - ); - if let Some(editor) = app.editor.as_mut() { - editor.cursor_row = 1; - editor.cursor_col = "alpha beta".chars().count(); + fn provider_add_form_hermes_models_enter_opens_model_picker() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); + + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.hermes_base_url.set("https://api.example.com/v1"); + form.hermes_api_key.set("sk-hermes"); + form.field_idx = form + .fields() + .iter() + .position(|field| *field == ProviderAddField::HermesModels) + .expect("HermesModels field should exist"); } - app.on_key(ctrl(KeyCode::Char('a')), &data()); - assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0); + let action = app.on_key(key(KeyCode::Enter), &data()); + assert!(matches!(action, Action::None)); + assert!( + app.editor.is_none(), + "Hermes Models Enter should not open a JSON editor" + ); + assert!(matches!( + app.overlay, + Overlay::HermesModelsPicker { editing: false } + )); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert!(matches!(form.page, form::ProviderFormPage::Main)); + } - app.on_key(ctrl(KeyCode::Char('e')), &data()); - app.on_key(ctrl(KeyCode::Char('w')), &data()); - assert_eq!(app.editor.as_ref().unwrap().lines[1], "alpha "); + #[test] + fn provider_add_form_hermes_navigation_skips_advanced_divider() { + let mut app = App::new(Some(AppType::Hermes)); + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - app.on_key(alt(KeyCode::Char('b')), &data()); - assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.editing = false; + let fields = form.fields(); + form.field_idx = fields + .iter() + .position(|field| *field == ProviderAddField::HermesModels) + .expect("HermesModels field should exist"); + } else { + panic!("expected ProviderAdd form"); + } + + app.on_key(key(KeyCode::Down), &UiData::default()); + assert!(matches!( + app.form, + Some(FormState::ProviderAdd(ref form)) + if form.fields().get(form.field_idx) == Some(&ProviderAddField::HermesRateLimitDelay) + )); + + app.on_key(key(KeyCode::Up), &UiData::default()); + assert!(matches!( + app.form, + Some(FormState::ProviderAdd(ref form)) + if form.fields().get(form.field_idx) == Some(&ProviderAddField::HermesModels) + )); } #[test] - fn tab_key_is_noop() { - let mut app = App::new(Some(AppType::Claude)); - app.route = Route::Providers; - app.focus = Focus::Nav; + fn provider_add_form_hermes_rate_limit_delay_rejects_invalid_number() { + let mut app = App::new(Some(AppType::Hermes)); + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); + + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.id.set("hermes-provider"); + form.name.set("Hermes Provider"); + form.hermes_base_url.set("https://api.example.com/v1"); + form.hermes_rate_limit_delay.set(".."); + } else { + panic!("expected ProviderAdd form"); + } + + let action = app.on_key(ctrl(KeyCode::Char('s')), &UiData::default()); - let data = UiData::default(); - let action = app.on_key(key(KeyCode::Tab), &data); assert!(matches!(action, Action::None)); - assert_eq!(app.focus, Focus::Nav); + assert!(matches!( + app.toast.as_ref(), + Some(toast) + if toast.kind == ToastKind::Warning + && toast.message == texts::tui_hermes_rate_limit_delay_invalid() + )); } #[test] - fn provider_json_editor_hides_internal_fields() { - let original = json!({ - "id": "p1", - "name": "demo", - "meta": { - "applyCommonConfig": true, - "custom_endpoints": { - "https://example.com": { - "url": "https://example.com" - } - } - }, - "icon": "openai", - "iconColor": "#00A67E", - "settingsConfig": { - "env": { - "ANTHROPIC_AUTH_TOKEN": "secret-token", - "FOO": "bar" - } - }, - "createdAt": 123, - "sortIndex": 9, - "category": "demo", - "inFailoverQueue": true - }); + fn provider_add_form_hermes_rate_limit_delay_sanitizes_to_number_chars() { + let mut app = App::new(Some(AppType::Hermes)); + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - let display = super::super::form::strip_provider_internal_fields(&original); - assert!(display.get("createdAt").is_none()); - assert!(display.get("meta").is_none()); - assert!(display.get("icon").is_none()); - assert!(display.get("iconColor").is_none()); - assert!(display.get("sortIndex").is_none()); - assert!(display.get("category").is_none()); - assert!(display.get("inFailoverQueue").is_none()); - assert_eq!( - display["settingsConfig"]["env"]["ANTHROPIC_AUTH_TOKEN"], - "secret-token" - ); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + let fields = form.fields(); + form.field_idx = fields + .iter() + .position(|field| *field == ProviderAddField::HermesRateLimitDelay) + .expect("HermesRateLimitDelay field should exist"); + } else { + panic!("expected ProviderAdd form"); + } + + app.on_key(key(KeyCode::Enter), &data()); + app.on_key(key(KeyCode::Char('a')), &data()); + app.on_key(key(KeyCode::Char('0')), &data()); + app.on_key(key(KeyCode::Char('.')), &data()); + app.on_key(key(KeyCode::Char('5')), &data()); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.hermes_rate_limit_delay.value, "0.5"); } #[test] - fn providers_enter_key_opens_detail() { - let mut app = App::new(Some(AppType::Claude)); + fn provider_add_form_hermes_models_picker_fetch_is_explicit() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - let mut data = UiData::default(); - data.providers.rows.push(super::super::data::ProviderRow { - id: "p1".to_string(), - provider: crate::provider::Provider::with_id( - "p1".to_string(), - "Provider One".to_string(), - json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}), - None, - ), - api_url: Some("https://example.com".to_string()), - is_current: false, - is_in_config: true, - is_saved: true, - is_default_model: false, - primary_model_id: None, - default_model_id: None, - }); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.hermes_base_url.set("https://api.example.com/v1"); + form.hermes_api_key.set("sk-hermes"); + form.open_hermes_models_picker(); + } + app.overlay = Overlay::HermesModelsPicker { editing: false }; - let action = app.on_key(key(KeyCode::Enter), &data); + let action = app.on_key(key(KeyCode::Char('f')), &data()); assert!(matches!( action, - Action::SwitchRoute(Route::ProviderDetail { id }) if id == "p1" + Action::ProviderModelFetch { + base_url, + api_key: Some(api_key), + field: ProviderAddField::HermesModels, + claude_idx: None, + } if base_url == "https://api.example.com/v1" && api_key == "sk-hermes" )); } #[test] - fn providers_enter_key_imports_current_config_when_empty() { - let mut app = App::new(Some(AppType::Claude)); + fn provider_add_form_hermes_models_picker_enter_does_not_fetch_or_add_models() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.hermes_base_url.set("https://api.example.com/v1"); + form.hermes_api_key.set("sk-hermes"); + form.open_hermes_models_picker(); + form.hermes_models_field_idx = 0; + } + app.overlay = Overlay::HermesModelsPicker { editing: false }; - assert!(matches!(action, Action::ProviderImportLiveConfig)); - assert!(matches!(app.overlay, Overlay::None)); + let action = app.on_key(key(KeyCode::Enter), &data()); + assert!(matches!(action, Action::None)); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert!( + form.hermes_models.is_empty(), + "fetching should only open the fetched-model picker" + ); } #[test] - fn providers_i_key_is_noop() { - let mut app = App::new(Some(AppType::Claude)); + fn provider_add_form_hermes_models_picker_fetch_requires_base_url_and_api_key() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - let action = app.on_key(key(KeyCode::Char('i')), &UiData::default()); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.open_hermes_models_picker(); + } + app.overlay = Overlay::HermesModelsPicker { editing: false }; + let action = app.on_key(key(KeyCode::Char('f')), &data()); assert!(matches!(action, Action::None)); - assert!(matches!(app.overlay, Overlay::None)); + assert!(matches!( + app.toast.as_ref(), + Some(toast) if toast.message == texts::tui_model_fetch_need_config() + )); } #[test] - fn providers_s_key_triggers_switch_action() { - let mut app = App::new(Some(AppType::Claude)); + fn provider_add_form_hermes_models_picker_add_edit_and_delete_model() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); - let mut data = UiData::default(); - data.providers.rows.push(super::super::data::ProviderRow { - id: "p1".to_string(), - provider: crate::provider::Provider::with_id( - "p1".to_string(), - "Provider One".to_string(), - json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}), - None, - ), - api_url: Some("https://example.com".to_string()), - is_current: false, - is_in_config: true, - is_saved: true, - is_default_model: false, + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.open_hermes_models_picker(); + } + app.overlay = Overlay::HermesModelsPicker { editing: false }; + + assert!(matches!( + app.on_key(key(KeyCode::Char('a')), &data()), + Action::None + )); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.hermes_models, vec![json!({ "id": "", "name": "" })]); + assert!(matches!( + form.selected_hermes_model_field(), + Some(form::HermesModelField::Id(0)) + )); + + app.on_key(key(KeyCode::Enter), &data()); + assert!(matches!( + app.overlay, + Overlay::HermesModelsPicker { editing: true } + )); + for ch in "gpt-5.4".chars() { + app.on_key(key(KeyCode::Char(ch)), &data()); + } + app.on_key(key(KeyCode::Enter), &data()); + assert!(matches!( + app.overlay, + Overlay::HermesModelsPicker { editing: false } + )); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.hermes_models[0]["id"], "gpt-5.4"); + assert_eq!(form.hermes_models[0]["name"], ""); + + assert!(matches!( + app.on_key(key(KeyCode::Char('d')), &data()), + Action::None + )); + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert!(form.hermes_models.is_empty()); + } + + #[test] + fn provider_add_form_hermes_models_picker_preserves_edit_cursor() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); + + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.open_hermes_models_picker(); + } + app.overlay = Overlay::HermesModelsPicker { editing: false }; + + app.on_key(key(KeyCode::Char('a')), &data()); + app.on_key(key(KeyCode::Enter), &data()); + for ch in "gpt-54".chars() { + app.on_key(key(KeyCode::Char(ch)), &data()); + } + app.on_key(key(KeyCode::Left), &data()); + app.on_key(key(KeyCode::Char('.')), &data()); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.hermes_models[0]["id"], "gpt-5.4"); + assert_eq!(form.hermes_model_input.cursor, "gpt-5.".chars().count()); + } + + #[test] + fn model_fetch_picker_hermes_models_sets_current_model_id_only() { + let mut app = App::new(Some(AppType::Hermes)); + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.open_hermes_models_picker(); + form.add_empty_hermes_model(); + form.set_hermes_model_field_text(form::HermesModelField::Name(0), "Display Name"); + } + app.overlay = Overlay::ModelFetchPicker { + request_id: 1, + field: ProviderAddField::HermesModels, + claude_idx: None, + input: TextInput::new("gpt-5.4"), + query: "gpt-5.4".to_string(), + fetching: false, + models: vec!["gpt-5.4".to_string()], + error: None, + selected_idx: 0, + }; + app.pending_overlay = Some(Overlay::HermesModelsPicker { editing: false }); + + let action = app.on_key(key(KeyCode::Enter), &data()); + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::HermesModelsPicker { editing: false } + )); + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.hermes_models.len(), 1); + assert_eq!(form.hermes_models[0]["id"], "gpt-5.4"); + assert_eq!(form.hermes_models[0]["name"], "Display Name"); + } + + #[test] + fn provider_add_form_hermes_id_input_is_sanitized_like_upstream() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(FormState::ProviderAdd(ProviderAddFormState::new( + AppType::Hermes, + ))); + + if let Some(FormState::ProviderAdd(form)) = app.form.as_mut() { + form.focus = FormFocus::Fields; + form.field_idx = 0; + form.editing = true; + } + + for ch in "My Provider_1".chars() { + app.on_key(key(KeyCode::Char(ch)), &data()); + } + + let Some(FormState::ProviderAdd(form)) = app.form.as_ref() else { + panic!("expected ProviderAdd form"); + }; + assert_eq!(form.id.value, "myprovider1"); + } + + #[test] + fn multiline_editor_supports_readline_shortcuts() { + let mut app = App::new(Some(AppType::Claude)); + app.open_editor( + "Prompt", + EditorKind::Plain, + "first line\nalpha beta", + EditorSubmit::PromptCreate { + id: "demo".to_string(), + name: "Demo".to_string(), + description: None, + }, + ); + if let Some(editor) = app.editor.as_mut() { + editor.cursor_row = 1; + editor.cursor_col = "alpha beta".chars().count(); + } + + app.on_key(ctrl(KeyCode::Char('a')), &data()); + assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0); + + app.on_key(ctrl(KeyCode::Char('e')), &data()); + app.on_key(ctrl(KeyCode::Char('w')), &data()); + assert_eq!(app.editor.as_ref().unwrap().lines[1], "alpha "); + + app.on_key(alt(KeyCode::Char('b')), &data()); + assert_eq!(app.editor.as_ref().unwrap().cursor_col, 0); + } + + #[test] + fn tab_key_is_noop() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Providers; + app.focus = Focus::Nav; + + let data = UiData::default(); + let action = app.on_key(key(KeyCode::Tab), &data); + assert!(matches!(action, Action::None)); + assert_eq!(app.focus, Focus::Nav); + } + + #[test] + fn provider_json_editor_hides_internal_fields() { + let original = json!({ + "id": "p1", + "name": "demo", + "meta": { + "applyCommonConfig": true, + "custom_endpoints": { + "https://example.com": { + "url": "https://example.com" + } + } + }, + "icon": "openai", + "iconColor": "#00A67E", + "settingsConfig": { + "env": { + "ANTHROPIC_AUTH_TOKEN": "secret-token", + "FOO": "bar" + } + }, + "createdAt": 123, + "sortIndex": 9, + "category": "demo", + "inFailoverQueue": true + }); + + let display = super::super::form::strip_provider_internal_fields(&original); + assert!(display.get("createdAt").is_none()); + assert!(display.get("meta").is_none()); + assert!(display.get("icon").is_none()); + assert!(display.get("iconColor").is_none()); + assert!(display.get("sortIndex").is_none()); + assert!(display.get("category").is_none()); + assert!(display.get("inFailoverQueue").is_none()); + assert_eq!( + display["settingsConfig"]["env"]["ANTHROPIC_AUTH_TOKEN"], + "secret-token" + ); + } + + #[test] + fn providers_enter_key_opens_detail() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: None, + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Enter), &data); + assert!(matches!( + action, + Action::SwitchRoute(Route::ProviderDetail { id }) if id == "p1" + )); + } + + #[test] + fn providers_enter_key_imports_current_config_when_empty() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let action = app.on_key(key(KeyCode::Enter), &UiData::default()); + + assert!(matches!(action, Action::ProviderImportLiveConfig)); + assert!(matches!(app.overlay, Overlay::None)); + } + + #[test] + fn providers_i_key_is_noop() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let action = app.on_key(key(KeyCode::Char('i')), &UiData::default()); + + assert!(matches!(action, Action::None)); + assert!(matches!(app.overlay, Overlay::None)); + } + + #[test] + fn providers_s_key_triggers_switch_action() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({"env":{"ANTHROPIC_BASE_URL":"https://example.com"}}), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, primary_model_id: None, default_model_id: None, }); @@ -1647,21 +2066,141 @@ mod tests { let mut data = UiData::default(); data.providers.rows.push(claude_provider_row("p1")); - let action = app.on_key(key(KeyCode::Char('c')), &data); + let action = app.on_key(key(KeyCode::Char('c')), &data); + + assert!(matches!(action, Action::None)); + assert!(matches!( + app.overlay, + Overlay::ProviderTestMenu { + ref provider_id, + selected: 1 + } if provider_id == "p1" + )); + } + + #[test] + fn openclaw_providers_s_key_adds_or_removes_live_config_membership() { + let mut app = App::new(Some(AppType::OpenClaw)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({"apiKey":"sk-demo","baseUrl":"https://example.com"}), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: false, + is_saved: true, + is_default_model: false, + primary_model_id: Some("claude-sonnet-4".to_string()), + default_model_id: None, + }); + + let add_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(add_action, Action::ProviderSwitch { id } if id == "p1")); + + data.providers.rows[0].is_in_config = true; + let remove_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(remove_action, Action::None)); + assert_provider_remove_confirm(&app, "p1", "Provider One"); + } + + #[test] + fn opencode_providers_s_key_adds_or_removes_live_config_membership() { + let mut app = App::new(Some(AppType::OpenCode)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({"options":{"baseURL":"https://example.com"}}), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: false, + is_saved: true, + is_default_model: false, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let add_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(add_action, Action::ProviderSwitch { id } if id == "p1")); + + data.providers.rows[0].is_in_config = true; + let remove_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(remove_action, Action::None)); + assert_provider_remove_confirm(&app, "p1", "Provider One"); + } + + #[test] + fn hermes_providers_s_key_adds_or_prompts_to_remove_live_config_membership() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "base_url": "https://example.com", + "api_key": "sk-demo", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: false, + is_saved: true, + is_default_model: false, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let add_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(add_action, Action::ProviderSwitch { id } if id == "p1")); + + data.providers.rows[0].is_in_config = true; + let remove_action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(remove_action, Action::None)); + assert_provider_remove_confirm(&app, "p1", "Provider One"); + } + + #[test] + fn provider_remove_confirm_enter_removes_from_config() { + let mut app = App::new(Some(AppType::OpenCode)); + app.overlay = Overlay::Confirm(ConfirmOverlay { + title: texts::tui_confirm_remove_provider_title().to_string(), + message: texts::tui_confirm_remove_provider_message("Provider One"), + action: ConfirmAction::ProviderRemoveFromConfig { + id: "p1".to_string(), + }, + }); + + let action = app.on_key(key(KeyCode::Enter), &data()); - assert!(matches!(action, Action::None)); - assert!(matches!( - app.overlay, - Overlay::ProviderTestMenu { - ref provider_id, - selected: 1 - } if provider_id == "p1" - )); + assert!(matches!(action, Action::ProviderRemoveFromConfig { id } if id == "p1")); + assert!(matches!(app.overlay, Overlay::None)); } #[test] - fn openclaw_providers_s_key_adds_or_removes_live_config_membership() { - let mut app = App::new(Some(AppType::OpenClaw)); + fn hermes_providers_x_key_enables_provider_from_live_config() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; @@ -1671,29 +2210,33 @@ mod tests { provider: crate::provider::Provider::with_id( "p1".to_string(), "Provider One".to_string(), - json!({"apiKey":"sk-demo","baseUrl":"https://example.com"}), + json!({ + "base_url": "https://example.com", + "api_key": "sk-demo", + "models": [{"id": "main"}] + }), None, ), api_url: Some("https://example.com".to_string()), is_current: false, - is_in_config: false, + is_in_config: true, is_saved: true, is_default_model: false, - primary_model_id: Some("claude-sonnet-4".to_string()), + primary_model_id: Some("main".to_string()), default_model_id: None, }); - let add_action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(add_action, Action::ProviderSwitch { id } if id == "p1")); - - data.providers.rows[0].is_in_config = true; - let remove_action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(remove_action, Action::ProviderRemoveFromConfig { id } if id == "p1")); + let action = app.on_key(key(KeyCode::Char('x')), &data); + assert!(matches!( + action, + Action::ProviderSetDefaultModel { provider_id, model_id } + if provider_id == "p1" && model_id == "main" + )); } #[test] - fn opencode_providers_s_key_adds_or_removes_live_config_membership() { - let mut app = App::new(Some(AppType::OpenCode)); + fn hermes_providers_s_key_blocks_removing_active_provider() { + let mut app = App::new(Some(AppType::Hermes)); app.route = Route::Providers; app.focus = Focus::Content; @@ -1703,24 +2246,26 @@ mod tests { provider: crate::provider::Provider::with_id( "p1".to_string(), "Provider One".to_string(), - json!({"options":{"baseURL":"https://example.com"}}), + json!({ + "base_url": "https://example.com", + "api_key": "sk-demo", + "models": [{"id": "main"}] + }), None, ), api_url: Some("https://example.com".to_string()), - is_current: false, - is_in_config: false, + is_current: true, + is_in_config: true, is_saved: true, - is_default_model: false, + is_default_model: true, primary_model_id: Some("main".to_string()), default_model_id: None, }); - let add_action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(add_action, Action::ProviderSwitch { id } if id == "p1")); - - data.providers.rows[0].is_in_config = true; - let remove_action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(remove_action, Action::ProviderRemoveFromConfig { id } if id == "p1")); + let action = app.on_key(key(KeyCode::Char('s')), &data); + assert!(matches!(action, Action::None)); + assert!(app.toast.is_some()); + assert!(matches!(app.overlay, Overlay::None)); } #[test] @@ -1756,6 +2301,48 @@ mod tests { assert!(app.toast.is_none(), "saved-only edit should not be blocked"); } + #[test] + fn hermes_providers_e_key_blocks_read_only_provider() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "remote".to_string(), + provider: crate::provider::Provider::with_id( + "remote".to_string(), + "Remote".to_string(), + json!({ + "_cc_source": crate::hermes_config::PROVIDER_SOURCE_DICT, + "base_url": "https://example.com", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Char('e')), &data); + assert!(matches!(action, Action::None)); + assert!( + app.form.is_none(), + "read-only provider should not open edit form" + ); + assert!(matches!( + app.toast.as_ref(), + Some(toast) + if toast.kind == ToastKind::Info + && toast.message == texts::tui_toast_provider_managed_by_hermes() + )); + } + #[test] fn openclaw_providers_x_key_sets_default_model_from_selected_provider() { let mut app = App::new(Some(AppType::OpenClaw)); @@ -1813,7 +2400,8 @@ mod tests { }); let action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(action, Action::ProviderRemoveFromConfig { id } if id == "p2")); + assert!(matches!(action, Action::None)); + assert_provider_remove_confirm(&app, "p2", "Provider Two"); assert!(app.toast.is_none()); } @@ -1917,6 +2505,88 @@ mod tests { ); } + #[test] + fn hermes_providers_d_key_allows_deleting_current_writable_provider() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "local".to_string(), + provider: crate::provider::Provider::with_id( + "local".to_string(), + "Local".to_string(), + json!({ + "_cc_source": crate::hermes_config::PROVIDER_SOURCE_CUSTOM_LIST, + "base_url": "https://example.com", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: true, + is_in_config: true, + is_saved: true, + is_default_model: true, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Char('d')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!( + &app.overlay, + Overlay::Confirm(ConfirmOverlay { + action: ConfirmAction::ProviderDelete { id }, + .. + }) if id == "local" + )); + assert!( + app.toast.is_none(), + "Hermes additive current provider delete should not be blocked" + ); + } + + #[test] + fn hermes_providers_d_key_blocks_read_only_provider() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "remote".to_string(), + provider: crate::provider::Provider::with_id( + "remote".to_string(), + "Remote".to_string(), + json!({ + "_cc_source": crate::hermes_config::PROVIDER_SOURCE_DICT, + "base_url": "https://example.com", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Char('d')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!(app.overlay, Overlay::None)); + assert!(matches!( + app.toast.as_ref(), + Some(toast) + if toast.kind == ToastKind::Info + && toast.message == texts::tui_toast_provider_managed_by_hermes() + )); + } + #[test] fn openclaw_providers_x_key_can_reset_default_back_to_primary_model() { let mut app = App::new(Some(AppType::OpenClaw)); @@ -2085,6 +2755,44 @@ mod tests { )); } + #[test] + fn hermes_provider_detail_x_key_enables_provider() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::ProviderDetail { + id: "p1".to_string(), + }; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "p1".to_string(), + provider: crate::provider::Provider::with_id( + "p1".to_string(), + "Provider One".to_string(), + json!({ + "base_url": "https://example.com", + "api_key": "sk-demo", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: false, + is_in_config: true, + is_saved: true, + is_default_model: false, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Char('x')), &data); + assert!(matches!( + action, + Action::ProviderSetDefaultModel { provider_id, model_id } + if provider_id == "p1" && model_id == "main" + )); + } + #[test] fn openclaw_provider_detail_e_key_allows_editing_saved_only_provider() { let mut app = App::new(Some(AppType::OpenClaw)); @@ -2120,6 +2828,50 @@ mod tests { assert!(app.toast.is_none(), "saved-only edit should not be blocked"); } + #[test] + fn hermes_provider_detail_blocks_read_only_edit() { + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::ProviderDetail { + id: "remote".to_string(), + }; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.providers.rows.push(super::super::data::ProviderRow { + id: "remote".to_string(), + provider: crate::provider::Provider::with_id( + "remote".to_string(), + "Remote".to_string(), + json!({ + "_cc_source": crate::hermes_config::PROVIDER_SOURCE_DICT, + "base_url": "https://example.com", + "models": [{"id": "main"}] + }), + None, + ), + api_url: Some("https://example.com".to_string()), + is_current: true, + is_in_config: true, + is_saved: true, + is_default_model: true, + primary_model_id: Some("main".to_string()), + default_model_id: None, + }); + + let action = app.on_key(key(KeyCode::Char('e')), &data); + assert!(matches!(action, Action::None)); + assert!( + app.form.is_none(), + "read-only provider should not open edit form" + ); + assert!(matches!( + app.toast.as_ref(), + Some(toast) + if toast.kind == ToastKind::Info + && toast.message == texts::tui_toast_provider_managed_by_hermes() + )); + } + #[test] fn openclaw_provider_detail_x_key_can_reset_default_back_to_primary_model() { let mut app = App::new(Some(AppType::OpenClaw)); @@ -2215,7 +2967,8 @@ mod tests { }); let action = app.on_key(key(KeyCode::Char('s')), &data); - assert!(matches!(action, Action::ProviderRemoveFromConfig { id } if id == "p2")); + assert!(matches!(action, Action::None)); + assert_provider_remove_confirm(&app, "p2", "Provider Two"); assert!(app.toast.is_none()); } @@ -2544,7 +3297,54 @@ mod tests { } #[test] - fn mcp_apps_picker_from_openclaw_targets_opencode_last_visible_row() { + fn mcp_apps_picker_can_select_hermes() { + let mut app = App::new(Some(AppType::Claude)); + app.route = Route::Mcp; + app.focus = Focus::Content; + + let mut data = UiData::default(); + data.mcp.rows.push(super::super::data::McpRow { + id: "m1".to_string(), + server: crate::app_config::McpServer { + id: "m1".to_string(), + name: "Server".to_string(), + server: json!({}), + apps: crate::app_config::McpApps::default(), + description: None, + homepage: None, + docs: None, + tags: vec![], + }, + }); + + app.on_key(key(KeyCode::Char('m')), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + app.on_key(key(KeyCode::Down), &data); + + let action = app.on_key(key(KeyCode::Char(' ')), &data); + assert!(matches!(action, Action::None)); + assert!(matches!( + &app.overlay, + Overlay::McpAppsPicker { selected, apps, .. } if *selected == 4 && apps.hermes + )); + + let action = app.on_key(key(KeyCode::Enter), &data); + assert!(matches!( + action, + Action::McpSetApps { id, apps } + if id == "m1" + && !apps.claude + && !apps.codex + && !apps.gemini + && !apps.opencode + && apps.hermes + )); + } + + #[test] + fn mcp_apps_picker_from_openclaw_targets_hermes_last_visible_row() { let mut app = App::new(Some(AppType::OpenClaw)); app.route = Route::Mcp; app.focus = Focus::Content; @@ -2568,7 +3368,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::McpAppsPicker { selected, .. } if *selected == 3 + Overlay::McpAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char(' ')), &data); @@ -2576,11 +3376,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::McpAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -8349,6 +9150,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -8396,6 +9198,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/app/types.rs b/src-tauri/src/cli/tui/app/types.rs index 0116f115..82242746 100644 --- a/src-tauri/src/cli/tui/app/types.rs +++ b/src-tauri/src/cli/tui/app/types.rs @@ -58,6 +58,7 @@ impl Toast { pub enum ConfirmAction { Quit, ProviderDelete { id: String }, + ProviderRemoveFromConfig { id: String }, McpDelete { id: String }, PromptDelete { id: String }, SkillsUninstall { directory: String }, @@ -210,6 +211,9 @@ pub enum Overlay { UsageQueryTemplatePicker { selected: usize, }, + HermesModelsPicker { + editing: bool, + }, ModelFetchPicker { request_id: u64, field: ProviderAddField, @@ -308,6 +312,7 @@ impl Overlay { match self { Overlay::TextInput(input) => input.is_editing(), Overlay::ClaudeModelPicker { editing, .. } => *editing, + Overlay::HermesModelsPicker { editing } => *editing, Overlay::ModelFetchPicker { .. } => true, Overlay::McpEnvEntryEditor(editor) => editor.is_editing(), Overlay::None diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index c5048367..dde3958b 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -7,6 +7,7 @@ use serde_json::Value; use crate::app_config::{AppType, CommonConfigSnippets, McpServer}; use crate::commands::workspace::{self, DailyMemoryFileInfo, ALLOWED_FILES}; use crate::error::AppError; +use crate::hermes_config::{HermesMemoryLimits, MemoryKind}; use crate::openclaw_config::{ OpenClawAgentsDefaults, OpenClawEnvConfig, OpenClawHealthWarning, OpenClawToolsConfig, }; @@ -214,6 +215,7 @@ pub struct ConfigSnapshot { pub openclaw_agents_defaults: Option, pub openclaw_warnings: Option>, pub openclaw_workspace: OpenClawWorkspaceSnapshot, + pub hermes_memory: HermesMemorySnapshot, } #[derive(Debug, Clone, Default)] @@ -223,6 +225,37 @@ pub struct OpenClawWorkspaceSnapshot { pub daily_memory_files: Vec, } +#[derive(Debug, Clone, Default)] +pub struct HermesMemorySnapshot { + pub directory_path: PathBuf, + pub memory_content: String, + pub user_content: String, + pub limits: HermesMemoryLimits, +} + +impl HermesMemorySnapshot { + pub fn content(&self, kind: MemoryKind) -> &str { + match kind { + MemoryKind::Memory => &self.memory_content, + MemoryKind::User => &self.user_content, + } + } + + pub fn limit(&self, kind: MemoryKind) -> usize { + match kind { + MemoryKind::Memory => self.limits.memory, + MemoryKind::User => self.limits.user, + } + } + + pub fn enabled(&self, kind: MemoryKind) -> bool { + match kind { + MemoryKind::Memory => self.limits.memory_enabled, + MemoryKind::User => self.limits.user_enabled, + } + } +} + #[derive(Debug, Clone, Default)] pub struct SkillsSnapshot { pub installed: Vec, @@ -266,6 +299,7 @@ impl ProxySnapshot { AppType::Codex => Some(self.codex_takeover), AppType::Gemini => Some(self.gemini_takeover), AppType::OpenCode => None, + AppType::Hermes => None, AppType::OpenClaw => None, } } @@ -346,6 +380,16 @@ pub(crate) fn provider_display_name(app_type: &AppType, row: &ProviderRow) -> St row.provider.name.clone() } +pub(crate) fn provider_is_read_only(app_type: &AppType, row: &ProviderRow) -> bool { + matches!(app_type, AppType::Hermes) + && row + .provider + .settings_config + .get(crate::hermes_config::PROVIDER_SOURCE_FIELD) + .and_then(Value::as_str) + == Some(crate::hermes_config::PROVIDER_SOURCE_DICT) +} + pub(crate) fn quota_target_for_current_provider( app_type: &AppType, data: &UiData, @@ -578,6 +622,19 @@ fn load_providers(state: &AppState, app_type: &AppType) -> Result>() + } else { + HashSet::new() + }; + let hermes_current_provider_id = if matches!(app_type, AppType::Hermes) { + crate::hermes_config::get_current_provider_id()? + } else { + None + }; let openclaw_default_model = if matches!(app_type, AppType::OpenClaw) { crate::openclaw_config::get_default_model()? } else { @@ -600,15 +657,21 @@ fn load_providers(state: &AppState, app_type: &AppType) -> Result hermes_current_provider_id.as_deref() == Some(id.as_str()), + _ => id == current_id, + }, is_in_config: match app_type { AppType::OpenCode => opencode_live_ids.contains(&id), + AppType::Hermes => hermes_live_ids.contains(&id), AppType::OpenClaw => openclaw_live_ids.contains(&id), _ => true, }, is_saved: true, - is_default_model: openclaw_primary_default_provider_id.as_deref() - == Some(id.as_str()), + is_default_model: match app_type { + AppType::Hermes => hermes_current_provider_id.as_deref() == Some(id.as_str()), + _ => openclaw_primary_default_provider_id.as_deref() == Some(id.as_str()), + }, primary_model_id: extract_primary_model_id( &provider.settings_config, app_type, @@ -685,6 +748,13 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option settings_config + .get("base_url") + .or_else(|| settings_config.get("baseUrl")) + .or_else(|| settings_config.get("baseURL")) + .or_else(|| settings_config.get("endpoint"))? + .as_str() + .map(|s| s.to_string()), AppType::OpenClaw => settings_config .get("baseUrl") .or_else(|| settings_config.get("base_url"))? @@ -699,6 +769,7 @@ fn extract_primary_model_id( openclaw_live_provider: Option<&Value>, ) -> Option { match app_type { + AppType::Hermes => hermes_primary_model_id(settings_config), AppType::OpenClaw => match openclaw_live_provider { Some(live_provider) => openclaw_primary_model_id(live_provider), None => openclaw_primary_model_id(settings_config), @@ -707,6 +778,30 @@ fn extract_primary_model_id( } } +fn hermes_primary_model_id(provider_value: &Value) -> Option { + provider_value + .get("model") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + .or_else(|| { + provider_value + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + provider_value + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .map(ToOwned::to_owned) + }) +} + fn openclaw_provider_for_row( _id: &str, provider: Provider, @@ -832,6 +927,7 @@ fn load_config_snapshot(state: &AppState, app_type: &AppType) -> Result Result Result { + if !matches!(app_type, AppType::Hermes) { + return Ok(HermesMemorySnapshot::default()); + } + + Ok(HermesMemorySnapshot { + directory_path: crate::hermes_config::get_hermes_dir().join("memories"), + memory_content: crate::hermes_config::read_memory(MemoryKind::Memory)?, + user_content: crate::hermes_config::read_memory(MemoryKind::User)?, + limits: crate::hermes_config::read_memory_limits()?, + }) +} + pub(crate) fn load_proxy_config() -> Result, AppError> { let state = load_state()?; let runtime = tokio::runtime::Builder::new_current_thread() diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index 9b257f45..539a964a 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -37,6 +37,13 @@ pub const OPENCLAW_API_PROTOCOLS: [&str; 5] = [ "google-generative-ai", "bedrock-converse-stream", ]; +pub const HERMES_DEFAULT_API_MODE: &str = "chat_completions"; +pub const HERMES_API_MODES: [&str; 4] = [ + "chat_completions", + "anthropic_messages", + "codex_responses", + "bedrock_converse", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeminiAuthType { @@ -184,6 +191,12 @@ pub enum ProviderAddField { OpenCodeModelName, OpenCodeModelContextLimit, OpenCodeModelOutputLimit, + HermesApiMode, + HermesApiKey, + HermesBaseUrl, + HermesModels, + HermesAdvancedDivider, + HermesRateLimitDelay, CommonConfigDivider, CommonSnippet, IncludeCommonConfig, @@ -197,6 +210,13 @@ pub enum ProviderFormPage { UsageQuery, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HermesModelField { + Id(usize), + Name(usize), + ContextLength(usize), +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UsageQueryTemplate { Custom, @@ -234,6 +254,7 @@ pub enum McpAddField { AppCodex, AppGemini, AppOpenCode, + AppHermes, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -325,6 +346,14 @@ pub struct ProviderAddFormState { pub opencode_model_context_limit: TextInput, pub opencode_model_output_limit: TextInput, opencode_model_original_id: Option, + pub hermes_api_mode: String, + pub hermes_api_key: TextInput, + pub hermes_base_url: TextInput, + pub hermes_models: Vec, + pub hermes_models_field_idx: usize, + pub hermes_models_editing: bool, + pub hermes_model_input: TextInput, + pub hermes_rate_limit_delay: TextInput, initial_snapshot: Value, } diff --git a/src-tauri/src/cli/tui/form/mcp.rs b/src-tauri/src/cli/tui/form/mcp.rs index c6fcd7ae..f4dfd50c 100644 --- a/src-tauri/src/cli/tui/form/mcp.rs +++ b/src-tauri/src/cli/tui/form/mcp.rs @@ -181,6 +181,7 @@ impl McpAddFormState { McpAddField::AppCodex, McpAddField::AppGemini, McpAddField::AppOpenCode, + McpAddField::AppHermes, ]); fields @@ -198,7 +199,8 @@ impl McpAddFormState { | McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode => None, + | McpAddField::AppOpenCode + | McpAddField::AppHermes => None, } } @@ -214,7 +216,8 @@ impl McpAddFormState { | McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode => None, + | McpAddField::AppOpenCode + | McpAddField::AppHermes => None, } } @@ -306,6 +309,7 @@ impl McpAddFormState { "codex": self.apps.codex, "gemini": self.apps.gemini, "opencode": self.apps.opencode, + "hermes": self.apps.hermes, }), ); diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 8c875196..be573f62 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -287,6 +287,37 @@ impl ProviderAddFormState { settings_obj.insert("models".to_string(), models_value); } } + AppType::Hermes => { + for legacy_key in ["api", "apiKey", "apiMode", "baseUrl", "baseURL", "endpoint"] { + settings_obj.remove(legacy_key); + } + + settings_obj.insert("api_mode".to_string(), json!(self.hermes_api_mode_value())); + + let base_url = self + .hermes_base_url + .value + .trim() + .trim_end_matches('/') + .to_string(); + set_or_remove_trimmed(settings_obj, "base_url", &base_url); + set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); + + if self.hermes_models.is_empty() { + settings_obj.remove("models"); + } else { + settings_obj.insert( + "models".to_string(), + Value::Array(self.hermes_models.clone()), + ); + } + + set_or_remove_f64( + settings_obj, + "rate_limit_delay", + &self.hermes_rate_limit_delay.value, + ); + } AppType::OpenClaw => { settings_obj.remove("npm"); settings_obj.remove("options"); @@ -656,7 +687,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::OpenClaw => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, @@ -744,3 +775,18 @@ fn set_or_remove_u64(obj: &mut serde_json::Map, key: &str, raw: & obj.remove(key); } } + +fn set_or_remove_f64(obj: &mut serde_json::Map, key: &str, raw: &str) { + let trimmed = raw.trim(); + if trimmed.is_empty() { + obj.remove(key); + } else if let Ok(value) = trimmed.parse::() { + if value.is_finite() && value >= 0.0 { + obj.insert(key.to_string(), json!(value)); + } else { + obj.remove(key); + } + } else { + obj.remove(key); + } +} diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index 75ae31b4..83725d77 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -10,8 +10,9 @@ use super::provider_json::{ use super::provider_state_loading::populate_form_from_provider; use super::{ ClaudeApiFormat, CodexPreviewSection, CodexWireApi, FormFocus, FormMode, GeminiAuthType, - ProviderAddField, ProviderAddFormState, ProviderFormPage, TextInput, UsageQueryField, - UsageQueryTemplate, OPENCLAW_DEFAULT_API_PROTOCOL, + HermesModelField, ProviderAddField, ProviderAddFormState, ProviderFormPage, TextInput, + UsageQueryField, UsageQueryTemplate, HERMES_API_MODES, HERMES_DEFAULT_API_MODE, + OPENCLAW_DEFAULT_API_PROTOCOL, }; impl ProviderAddFormState { @@ -156,6 +157,14 @@ impl ProviderAddFormState { opencode_model_context_limit: TextInput::new(""), opencode_model_output_limit: TextInput::new(""), opencode_model_original_id: None, + hermes_api_mode: HERMES_DEFAULT_API_MODE.to_string(), + hermes_api_key: TextInput::new(""), + hermes_base_url: TextInput::new(""), + hermes_models: Vec::new(), + hermes_models_field_idx: 0, + hermes_models_editing: false, + hermes_model_input: TextInput::new(""), + hermes_rate_limit_delay: TextInput::new(""), initial_snapshot: Value::Null, }; form.capture_initial_snapshot(); @@ -230,7 +239,7 @@ impl ProviderAddFormState { .ok() .and_then(|value| value.as_object().cloned()) .is_some_and(|obj| !obj.is_empty()), - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -285,7 +294,7 @@ impl ProviderAddFormState { ProviderAddField::Notes, ]; - if matches!(self.app_type, AppType::OpenClaw) { + if matches!(self.app_type, AppType::Hermes | AppType::OpenClaw) { fields.insert(0, ProviderAddField::Id); } @@ -323,6 +332,14 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenCodeModelContextLimit); fields.push(ProviderAddField::OpenCodeModelOutputLimit); } + AppType::Hermes => { + fields.push(ProviderAddField::HermesApiMode); + fields.push(ProviderAddField::HermesBaseUrl); + fields.push(ProviderAddField::HermesApiKey); + fields.push(ProviderAddField::HermesModels); + fields.push(ProviderAddField::HermesAdvancedDivider); + fields.push(ProviderAddField::HermesRateLimitDelay); + } AppType::OpenClaw => { fields.push(ProviderAddField::OpenClawApiProtocol); fields.push(ProviderAddField::OpenCodeApiKey); @@ -429,6 +446,9 @@ impl ProviderAddFormState { ProviderAddField::OpenCodeModelName => Some(&self.opencode_model_name), ProviderAddField::OpenCodeModelContextLimit => Some(&self.opencode_model_context_limit), ProviderAddField::OpenCodeModelOutputLimit => Some(&self.opencode_model_output_limit), + ProviderAddField::HermesApiKey => Some(&self.hermes_api_key), + ProviderAddField::HermesBaseUrl => Some(&self.hermes_base_url), + ProviderAddField::HermesRateLimitDelay => Some(&self.hermes_rate_limit_delay), ProviderAddField::CodexWireApi | ProviderAddField::CodexRequiresOpenaiAuth | ProviderAddField::ClaudeApiFormat @@ -438,6 +458,9 @@ impl ProviderAddFormState { | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels + | ProviderAddField::HermesAdvancedDivider | ProviderAddField::CommonConfigDivider | ProviderAddField::CommonSnippet | ProviderAddField::IncludeCommonConfig @@ -472,6 +495,9 @@ impl ProviderAddFormState { ProviderAddField::OpenCodeModelOutputLimit => { Some(&mut self.opencode_model_output_limit) } + ProviderAddField::HermesApiKey => Some(&mut self.hermes_api_key), + ProviderAddField::HermesBaseUrl => Some(&mut self.hermes_base_url), + ProviderAddField::HermesRateLimitDelay => Some(&mut self.hermes_rate_limit_delay), ProviderAddField::CodexWireApi | ProviderAddField::CodexRequiresOpenaiAuth | ProviderAddField::ClaudeApiFormat @@ -481,6 +507,9 @@ impl ProviderAddFormState { | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels + | ProviderAddField::HermesAdvancedDivider | ProviderAddField::CommonConfigDivider | ProviderAddField::CommonSnippet | ProviderAddField::IncludeCommonConfig @@ -557,6 +586,175 @@ impl ProviderAddFormState { self.usage_query_editing = false; } + pub fn open_hermes_models_picker(&mut self) { + if !matches!(self.app_type, AppType::Hermes) { + return; + } + self.focus = FormFocus::Fields; + self.editing = false; + self.hermes_models_editing = false; + let len = self.hermes_model_fields().len(); + self.hermes_models_field_idx = self.hermes_models_field_idx.min(len.saturating_sub(1)); + self.sync_hermes_model_input_from_selection(); + } + + pub fn close_hermes_models_picker(&mut self) { + self.hermes_models_editing = false; + self.hermes_model_input.set(""); + } + + pub fn hermes_model_fields(&self) -> Vec { + let mut fields = Vec::with_capacity(self.hermes_models.len().saturating_mul(3)); + for index in 0..self.hermes_models.len() { + fields.push(HermesModelField::Id(index)); + fields.push(HermesModelField::Name(index)); + fields.push(HermesModelField::ContextLength(index)); + } + fields + } + + pub fn selected_hermes_model_field(&self) -> Option { + let fields = self.hermes_model_fields(); + fields + .get( + self.hermes_models_field_idx + .min(fields.len().saturating_sub(1)), + ) + .copied() + } + + pub fn add_empty_hermes_model(&mut self) { + if !matches!(self.app_type, AppType::Hermes) { + return; + } + self.hermes_models.push(json!({ "id": "", "name": "" })); + self.hermes_models_field_idx = self + .hermes_model_fields() + .iter() + .position(|field| matches!(field, HermesModelField::Id(index) if *index == self.hermes_models.len().saturating_sub(1))) + .unwrap_or(self.hermes_models_field_idx); + self.sync_hermes_model_input_from_selection(); + } + + pub fn remove_hermes_model(&mut self, index: usize) { + if index >= self.hermes_models.len() { + return; + } + self.hermes_models.remove(index); + let fields_len = self.hermes_model_fields().len(); + self.hermes_models_field_idx = self + .hermes_models_field_idx + .min(fields_len.saturating_sub(1)); + self.hermes_models_editing = false; + self.sync_hermes_model_input_from_selection(); + } + + pub fn remove_selected_hermes_model(&mut self) -> bool { + let Some(field) = self.selected_hermes_model_field() else { + return false; + }; + let index = match field { + HermesModelField::Id(index) + | HermesModelField::Name(index) + | HermesModelField::ContextLength(index) => index, + }; + if index >= self.hermes_models.len() { + return false; + } + self.remove_hermes_model(index); + true + } + + pub fn hermes_model_field_input(&self, field: HermesModelField) -> Option { + let (index, key) = match field { + HermesModelField::Id(index) => (index, "id"), + HermesModelField::Name(index) => (index, "name"), + HermesModelField::ContextLength(index) => (index, "context_length"), + }; + let model = self.hermes_models.get(index)?; + let value = model + .get(key) + .and_then(|value| { + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_i64().map(|number| number.to_string())) + .or_else(|| value.as_u64().map(|number| number.to_string())) + }) + .unwrap_or_default(); + Some(TextInput::new(value)) + } + + pub fn sync_hermes_model_input_from_selection(&mut self) { + let input = self + .selected_hermes_model_field() + .and_then(|field| self.hermes_model_field_input(field)) + .unwrap_or_else(|| TextInput::new("")); + self.hermes_model_input = input; + } + + pub fn set_hermes_model_field_text(&mut self, field: HermesModelField, value: &str) { + let (index, key) = match field { + HermesModelField::Id(index) => (index, "id"), + HermesModelField::Name(index) => (index, "name"), + HermesModelField::ContextLength(index) => (index, "context_length"), + }; + let Some(model) = self.hermes_models.get_mut(index) else { + return; + }; + if !model.is_object() { + *model = json!({}); + } + let Some(obj) = model.as_object_mut() else { + return; + }; + let trimmed = value.trim(); + if key == "context_length" { + if trimmed.is_empty() { + obj.remove(key); + } else if let Ok(number) = trimmed.parse::() { + obj.insert(key.to_string(), json!(number)); + } else { + obj.insert(key.to_string(), json!(trimmed)); + } + } else { + obj.insert(key.to_string(), json!(value)); + } + } + + pub(crate) fn set_selected_hermes_model_id_from_picker(&mut self, model_id: &str) -> bool { + if !matches!(self.app_type, AppType::Hermes) { + return false; + } + let model_id = model_id.trim(); + if model_id.is_empty() { + return false; + } + + let selected = self.selected_hermes_model_field(); + let target_index = match selected { + Some(HermesModelField::Id(index)) if index < self.hermes_models.len() => index, + Some(HermesModelField::Name(index) | HermesModelField::ContextLength(index)) + if index < self.hermes_models.len() => + { + index + } + _ => { + self.add_empty_hermes_model(); + self.hermes_models.len().saturating_sub(1) + } + }; + + self.set_hermes_model_field_text(HermesModelField::Id(target_index), model_id); + self.hermes_models_field_idx = self + .hermes_model_fields() + .iter() + .position(|field| *field == HermesModelField::Id(target_index)) + .unwrap_or(self.hermes_models_field_idx); + self.sync_hermes_model_input_from_selection(); + true + } + pub fn touch_usage_query(&mut self) { self.usage_query_touched = true; } @@ -733,6 +931,7 @@ impl ProviderAddFormState { AppType::Claude => self.claude_base_url.value.clone(), AppType::Codex => self.codex_base_url.value.clone(), AppType::Gemini => self.gemini_base_url.value.clone(), + AppType::Hermes => self.hermes_base_url.value.clone(), AppType::OpenCode | AppType::OpenClaw => self.opencode_base_url.value.clone(), } } @@ -742,6 +941,7 @@ impl ProviderAddFormState { AppType::Claude => (&self.claude_api_key.value, &self.claude_base_url.value), AppType::Codex => (&self.codex_api_key.value, &self.codex_base_url.value), AppType::Gemini => (&self.gemini_api_key.value, &self.gemini_base_url.value), + AppType::Hermes => (&self.hermes_api_key.value, &self.hermes_base_url.value), AppType::OpenCode | AppType::OpenClaw => { (&self.opencode_api_key.value, &self.opencode_base_url.value) } @@ -868,6 +1068,7 @@ impl ProviderAddFormState { let previous_template_idx = self.template_idx; let previous_field_idx = self.field_idx; let previous_usage_query_field_idx = self.usage_query_field_idx; + let previous_hermes_models_field_idx = self.hermes_models_field_idx; let previous_json_scroll = self.json_scroll; let previous_codex_preview_section = self.codex_preview_section; let previous_codex_auth_scroll = self.codex_auth_scroll; @@ -905,6 +1106,7 @@ impl ProviderAddFormState { next.codex_config_scroll = previous_codex_config_scroll; next.editing = false; next.usage_query_editing = false; + next.hermes_models_editing = false; let fields_len = next.fields().len(); next.field_idx = if fields_len == 0 { 0 @@ -917,6 +1119,13 @@ impl ProviderAddFormState { } else { previous_usage_query_field_idx.min(usage_fields_len - 1) }; + let hermes_model_fields_len = next.hermes_model_fields().len(); + next.hermes_models_field_idx = if hermes_model_fields_len == 0 { + 0 + } else { + previous_hermes_models_field_idx.min(hermes_model_fields_len - 1) + }; + next.sync_hermes_model_input_from_selection(); if let FormMode::Edit { id } = previous_mode { next.id.set(id); @@ -937,6 +1146,7 @@ impl ProviderAddFormState { let previous_template_idx = self.template_idx; let previous_field_idx = self.field_idx; let previous_usage_query_field_idx = self.usage_query_field_idx; + let previous_hermes_models_field_idx = self.hermes_models_field_idx; let previous_json_scroll = self.json_scroll; let previous_codex_preview_section = self.codex_preview_section; let previous_codex_auth_scroll = self.codex_auth_scroll; @@ -984,6 +1194,7 @@ impl ProviderAddFormState { next.codex_config_scroll = previous_codex_config_scroll; next.editing = false; next.usage_query_editing = false; + next.hermes_models_editing = false; let fields_len = next.fields().len(); next.field_idx = if fields_len == 0 { @@ -997,6 +1208,13 @@ impl ProviderAddFormState { } else { previous_usage_query_field_idx.min(usage_fields_len - 1) }; + let hermes_model_fields_len = next.hermes_model_fields().len(); + next.hermes_models_field_idx = if hermes_model_fields_len == 0 { + 0 + } else { + previous_hermes_models_field_idx.min(hermes_model_fields_len - 1) + }; + next.sync_hermes_model_input_from_selection(); if let FormMode::Edit { id } = previous_mode { next.id.set(id); @@ -1055,6 +1273,29 @@ impl ProviderAddFormState { } } + pub(crate) fn cycle_hermes_api_mode(&mut self) { + let current = HERMES_API_MODES + .iter() + .position(|mode| *mode == self.hermes_api_mode.trim()) + .unwrap_or(0); + self.hermes_api_mode = HERMES_API_MODES[(current + 1) % HERMES_API_MODES.len()].to_string(); + } + + pub(crate) fn hermes_api_mode_value(&self) -> &str { + if HERMES_API_MODES + .iter() + .any(|mode| *mode == self.hermes_api_mode.trim()) + { + self.hermes_api_mode.trim() + } else { + HERMES_DEFAULT_API_MODE + } + } + + pub(crate) fn hermes_models_summary(&self) -> String { + texts::tui_hermes_models_summary(self.hermes_models.len()) + } + pub(crate) fn openclaw_models_summary(&self) -> String { let total = self.openclaw_models.len(); texts::tui_openclaw_models_summary(total) diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index 7cc7271b..18163e04 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -18,6 +18,7 @@ pub(super) fn populate_form_from_provider( AppType::Codex => populate_codex_form(form, provider), AppType::Gemini => populate_gemini_form(form, provider), AppType::OpenCode => populate_opencode_form(form, provider), + AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), } populate_usage_query_form(form, provider); @@ -255,6 +256,49 @@ fn populate_opencode_form(form: &mut ProviderAddFormState, provider: &Provider) } } +fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { + let settings = &provider.settings_config; + + if let Some(api_mode) = settings + .get("api_mode") + .or_else(|| settings.get("apiMode")) + .and_then(|value| value.as_str()) + { + if super::HERMES_API_MODES.iter().any(|mode| *mode == api_mode) { + form.hermes_api_mode = api_mode.to_string(); + } + } + + if let Some(base_url) = settings + .get("base_url") + .or_else(|| settings.get("baseUrl")) + .or_else(|| settings.get("baseURL")) + .or_else(|| settings.get("endpoint")) + .and_then(|value| value.as_str()) + { + form.hermes_base_url.set(base_url); + } + if let Some(api_key) = settings + .get("api_key") + .or_else(|| settings.get("apiKey")) + .or_else(|| settings.get("auth_token")) + .and_then(|value| value.as_str()) + { + form.hermes_api_key.set(api_key); + } + if let Some(models) = settings.get("models").and_then(|value| value.as_array()) { + form.hermes_models = models.clone(); + } + if let Some(delay) = settings + .get("rate_limit_delay") + .and_then(|value| value.as_f64()) + { + if delay.is_finite() && delay >= 0.0 { + form.hermes_rate_limit_delay.set(delay.to_string()); + } + } +} + fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index c3530a0a..b1621bc3 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -157,6 +157,11 @@ static PROVIDER_TEMPLATE_DEFS_OPENCODE: [ProviderTemplateDef; 1] = [ProviderTemp label: "Custom", }]; +static PROVIDER_TEMPLATE_DEFS_HERMES: [ProviderTemplateDef; 1] = [ProviderTemplateDef { + id: ProviderTemplateId::Custom, + label: "Custom", +}]; + static PROVIDER_TEMPLATE_DEFS_OPENCLAW: [ProviderTemplateDef; 1] = [ProviderTemplateDef { id: ProviderTemplateId::Custom, label: "Custom", @@ -168,6 +173,7 @@ pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [Pr AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX, AppType::Gemini => &PROVIDER_TEMPLATE_DEFS_GEMINI, AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE, + AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, } } @@ -178,6 +184,7 @@ pub(super) fn provider_sponsor_presets(app_type: &AppType) -> &'static [SponsorP AppType::Codex => &SPONSOR_PROVIDER_PRESETS_CODEX, AppType::Gemini => &SPONSOR_PROVIDER_PRESETS_GEMINI, AppType::OpenCode => &SPONSOR_PROVIDER_PRESETS_OPENCODE, + AppType::Hermes => &[], AppType::OpenClaw => &SPONSOR_PROVIDER_PRESETS_OPENCLAW, } } @@ -258,6 +265,11 @@ impl ProviderAddFormState { self.gemini_model = defaults.gemini_model; self.openclaw_user_agent = defaults.openclaw_user_agent; self.openclaw_models = defaults.openclaw_models; + self.hermes_api_mode = defaults.hermes_api_mode; + self.hermes_api_key = defaults.hermes_api_key; + self.hermes_base_url = defaults.hermes_base_url; + self.hermes_models = defaults.hermes_models; + self.hermes_rate_limit_delay = defaults.hermes_rate_limit_delay; self.opencode_npm_package = defaults.opencode_npm_package; self.opencode_api_key = defaults.opencode_api_key; self.opencode_base_url = defaults.opencode_base_url; @@ -381,6 +393,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = Some("claude-opus-4.6".to_string()); } } + AppType::Hermes => {} AppType::OpenClaw => { if preset.id == "aicodemirror" { self.opencode_api_key.set(""); diff --git a/src-tauri/src/cli/tui/form/tests.rs b/src-tauri/src/cli/tui/form/tests.rs index 7818184e..af04af6f 100644 --- a/src-tauri/src/cli/tui/form/tests.rs +++ b/src-tauri/src/cli/tui/form/tests.rs @@ -1176,6 +1176,7 @@ fn mcp_add_form_builds_server_and_apps() { form.apps.claude = true; form.apps.codex = false; form.apps.gemini = true; + form.apps.hermes = true; let server = form.to_mcp_server_json_value(); assert_eq!(server["id"], "m1"); @@ -1187,6 +1188,7 @@ fn mcp_add_form_builds_server_and_apps() { assert_eq!(server["apps"]["codex"], false); assert_eq!(server["apps"]["gemini"], true); assert_eq!(server["apps"]["opencode"], false); + assert_eq!(server["apps"]["hermes"], true); } #[test] @@ -1358,6 +1360,7 @@ fn mcp_http_form_replaces_stdio_fields_with_url() { assert!(!fields.contains(&McpAddField::Args)); assert!(!fields.contains(&McpAddField::Env)); assert!(fields.contains(&McpAddField::AppOpenCode)); + assert!(fields.contains(&McpAddField::AppHermes)); } #[test] @@ -1922,6 +1925,143 @@ fn provider_add_form_openclaw_uses_dedicated_template_defs() { ); } +#[test] +fn provider_add_form_hermes_exposes_upstream_provider_fields_only() { + let form = ProviderAddFormState::new(AppType::Hermes); + let fields = form.fields(); + + assert_eq!( + fields, + vec![ + ProviderAddField::Id, + ProviderAddField::Name, + ProviderAddField::WebsiteUrl, + ProviderAddField::Notes, + ProviderAddField::HermesApiMode, + ProviderAddField::HermesBaseUrl, + ProviderAddField::HermesApiKey, + ProviderAddField::HermesModels, + ProviderAddField::HermesAdvancedDivider, + ProviderAddField::HermesRateLimitDelay, + ProviderAddField::UsageQueryDivider, + ProviderAddField::UsageQuery, + ] + ); + assert!( + !fields.contains(&ProviderAddField::CommonSnippet), + "Hermes provider form should not expose common config controls" + ); +} + +#[test] +fn provider_add_form_hermes_rate_limit_delay_is_editable() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + let fields = form.fields(); + assert!(fields.contains(&ProviderAddField::HermesRateLimitDelay)); + assert!(form.input(ProviderAddField::HermesRateLimitDelay).is_some()); + assert!(form + .input_mut(ProviderAddField::HermesRateLimitDelay) + .is_some()); + assert!( + form.input(ProviderAddField::HermesAdvancedDivider) + .is_none(), + "Hermes advanced divider must not be editable" + ); + + form.hermes_rate_limit_delay.set("0.5"); + assert_eq!( + form.input(ProviderAddField::HermesRateLimitDelay) + .map(|input| input.value.as_str()), + Some("0.5") + ); +} + +#[test] +fn provider_add_form_hermes_builds_upstream_snake_case_settings() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + form.id.set("openrouter"); + form.name.set("OpenRouter"); + form.hermes_api_mode = "anthropic_messages".to_string(); + form.hermes_base_url + .set(" https://openrouter.ai/api/v1/// "); + form.hermes_api_key.set(" sk-or-test "); + form.hermes_models = vec![json!({ + "id": "anthropic/claude-opus-4-7", + "name": "Claude Opus 4.7", + "context_length": 1000000, + })]; + form.hermes_rate_limit_delay.set("0.5"); + + let provider = form.to_provider_json_value(); + let settings = provider["settingsConfig"].as_object().unwrap(); + assert_eq!(settings.get("api_mode"), Some(&json!("anthropic_messages"))); + assert_eq!( + settings.get("base_url"), + Some(&json!("https://openrouter.ai/api/v1")) + ); + assert_eq!(settings.get("api_key"), Some(&json!("sk-or-test"))); + assert_eq!(settings.get("rate_limit_delay"), Some(&json!(0.5))); + assert_eq!(settings["models"][0]["id"], "anthropic/claude-opus-4-7"); + for legacy_key in ["api", "apiKey", "apiMode", "baseUrl", "baseURL", "endpoint"] { + assert!( + !settings.contains_key(legacy_key), + "Hermes save should drop legacy alias {legacy_key}" + ); + } +} + +#[test] +fn provider_add_form_hermes_omits_optional_blank_values_but_writes_default_mode() { + let mut form = ProviderAddFormState::new(AppType::Hermes); + form.id.set("custom"); + form.name.set("Custom Hermes"); + + let provider = form.to_provider_json_value(); + let settings = provider["settingsConfig"].as_object().unwrap(); + assert_eq!(settings.get("api_mode"), Some(&json!("chat_completions"))); + assert!(settings.get("base_url").is_none()); + assert!(settings.get("api_key").is_none()); + assert!(settings.get("models").is_none()); + assert!(settings.get("rate_limit_delay").is_none()); +} + +#[test] +fn provider_add_form_hermes_loads_legacy_aliases_and_saves_canonical_shape() { + let provider = Provider::with_id( + "legacy".to_string(), + "Legacy Hermes".to_string(), + json!({ + "apiMode": "bedrock_converse", + "baseUrl": "https://legacy.example/v1", + "apiKey": "sk-legacy", + "api": "openai-completions", + "models": [ + { "id": "legacy-model", "name": "Legacy Model" } + ], + }), + None, + ); + + let form = ProviderAddFormState::from_provider(AppType::Hermes, &provider); + assert_eq!(form.hermes_api_mode_value(), "bedrock_converse"); + assert_eq!(form.hermes_base_url.value, "https://legacy.example/v1"); + assert_eq!(form.hermes_api_key.value, "sk-legacy"); + assert_eq!(form.hermes_models[0]["id"], "legacy-model"); + + let roundtrip = form.to_provider_json_value(); + let settings = roundtrip["settingsConfig"].as_object().unwrap(); + assert_eq!(settings.get("api_mode"), Some(&json!("bedrock_converse"))); + assert_eq!( + settings.get("base_url"), + Some(&json!("https://legacy.example/v1")) + ); + assert_eq!(settings.get("api_key"), Some(&json!("sk-legacy"))); + assert!(settings.get("api").is_none()); + assert!(settings.get("apiMode").is_none()); + assert!(settings.get("baseUrl").is_none()); + assert!(settings.get("apiKey").is_none()); +} + #[test] fn provider_add_form_aicodemirror_template_opencode_matches_serializer_and_loader_semantics() { let mut form = ProviderAddFormState::new(AppType::OpenCode); diff --git a/src-tauri/src/cli/tui/route.rs b/src-tauri/src/cli/tui/route.rs index 07d0082f..cdf7c8d4 100644 --- a/src-tauri/src/cli/tui/route.rs +++ b/src-tauri/src/cli/tui/route.rs @@ -7,6 +7,7 @@ pub enum Route { ProviderDetail { id: String }, Mcp, Prompts, + HermesMemory, Config, ConfigOpenClawWorkspace, ConfigOpenClawDailyMemory, @@ -28,6 +29,7 @@ pub enum NavItem { Providers, Mcp, Prompts, + HermesMemory, Config, Skills, OpenClawWorkspace, @@ -61,11 +63,21 @@ impl NavItem { NavItem::Exit, ]; + pub const HERMES_ALL: [NavItem; 7] = [ + NavItem::Main, + NavItem::Providers, + NavItem::Skills, + NavItem::HermesMemory, + NavItem::Mcp, + NavItem::Settings, + NavItem::Exit, + ]; + pub fn all_for_app(app_type: &AppType) -> &'static [NavItem] { - if matches!(app_type, AppType::OpenClaw) { - &Self::OPENCLAW_ALL - } else { - &Self::ALL + match app_type { + AppType::OpenClaw => &Self::OPENCLAW_ALL, + AppType::Hermes => &Self::HERMES_ALL, + _ => &Self::ALL, } } @@ -75,6 +87,7 @@ impl NavItem { NavItem::Providers => Some(Route::Providers), NavItem::Mcp => Some(Route::Mcp), NavItem::Prompts => Some(Route::Prompts), + NavItem::HermesMemory => Some(Route::HermesMemory), NavItem::Config => Some(Route::Config), NavItem::Skills => Some(Route::Skills), NavItem::OpenClawWorkspace => Some(Route::ConfigOpenClawWorkspace), @@ -107,4 +120,14 @@ mod tests { "skills should appear above prompts in the left nav" ); } + + #[test] + fn hermes_nav_uses_memory_instead_of_prompts() { + assert!(NavItem::HERMES_ALL + .iter() + .any(|item| matches!(item, NavItem::HermesMemory))); + assert!(!NavItem::HERMES_ALL + .iter() + .any(|item| matches!(item, NavItem::Prompts))); + } } diff --git a/src-tauri/src/cli/tui/runtime_actions/config.rs b/src-tauri/src/cli/tui/runtime_actions/config.rs index add21ced..c1df6a35 100644 --- a/src-tauri/src/cli/tui/runtime_actions/config.rs +++ b/src-tauri/src/cli/tui/runtime_actions/config.rs @@ -1,7 +1,11 @@ +use std::path::Path; +use std::process::Command; + use crate::app_config::AppType; use crate::cli::i18n::texts; use crate::commands::workspace; use crate::error::AppError; +use crate::hermes_config::MemoryKind; use crate::services::ConfigService; use crate::settings::set_webdav_sync_settings; @@ -261,6 +265,96 @@ pub(super) fn open_openclaw_daily_memory_file( Ok(()) } +pub(super) fn open_hermes_memory( + ctx: &mut RuntimeActionContext<'_>, + kind: MemoryKind, +) -> Result<(), AppError> { + let content = crate::hermes_config::read_memory(kind)?; + ctx.app.open_editor( + texts::tui_hermes_memory_editor_title(hermes_memory_kind_label(kind)), + crate::cli::tui::app::EditorKind::Plain, + content, + crate::cli::tui::app::EditorSubmit::HermesMemory { kind }, + ); + Ok(()) +} + +pub(super) fn set_hermes_memory_enabled( + ctx: &mut RuntimeActionContext<'_>, + kind: MemoryKind, + enabled: bool, +) -> Result<(), AppError> { + crate::hermes_config::set_memory_enabled(kind, enabled)?; + ctx.app.push_toast( + texts::tui_hermes_memory_toggle_saved(hermes_memory_kind_label(kind), enabled), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +pub(super) fn open_hermes_memory_directory( + ctx: &mut RuntimeActionContext<'_>, +) -> Result<(), AppError> { + let target_dir = crate::hermes_config::get_hermes_dir().join("memories"); + std::fs::create_dir_all(&target_dir).map_err(|error| AppError::io(&target_dir, error))?; + if let Err(err) = open_directory(&target_dir) { + ctx.app.push_toast( + texts::tui_hermes_memory_directory_open_failed(&err), + ToastKind::Error, + ); + } + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + +pub(crate) fn hermes_memory_kind_label(kind: MemoryKind) -> &'static str { + match kind { + MemoryKind::Memory => texts::tui_hermes_memory_agent_tab(), + MemoryKind::User => texts::tui_hermes_memory_user_tab(), + } +} + +fn open_directory(path: &Path) -> Result { + if std::env::var_os("CC_SWITCH_TEST_DISABLE_OPEN").is_some() { + return Ok(true); + } + + #[cfg(target_os = "macos")] + let mut command = { + let mut command = Command::new("open"); + command.arg(path); + command + }; + + #[cfg(target_os = "linux")] + let mut command = { + let mut command = Command::new("xdg-open"); + command.arg(path); + command + }; + + #[cfg(target_os = "windows")] + let mut command = { + let mut command = Command::new("explorer"); + command.arg(path); + command + }; + + let status = command + .status() + .map_err(|error| format!("Failed to open directory {}: {error}", path.display()))?; + + if status.success() { + Ok(true) + } else { + Err(format!( + "Failed to open directory {}: opener exited with status {status}", + path.display() + )) + } +} + pub(super) fn search_openclaw_daily_memory( ctx: &mut RuntimeActionContext<'_>, query: String, diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 47924ddb..8d77015f 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -269,6 +269,7 @@ pub(super) fn submit( EditorSubmit::OpenClawDailyMemoryFile { filename } => { submit_openclaw_daily_memory_file(ctx, filename, content) } + EditorSubmit::HermesMemory { kind } => submit_hermes_memory(ctx, kind, content), EditorSubmit::ConfigOpenClawEnv => submit_openclaw_env(ctx, content), EditorSubmit::ConfigOpenClawTools => submit_openclaw_tools(ctx, content), EditorSubmit::ConfigOpenClawAgents => submit_openclaw_agents(ctx, content), @@ -276,6 +277,21 @@ pub(super) fn submit( } } +fn submit_hermes_memory( + ctx: &mut RuntimeActionContext<'_>, + kind: crate::hermes_config::MemoryKind, + content: String, +) -> Result<(), AppError> { + crate::hermes_config::write_memory(kind, &content)?; + ctx.app.editor = None; + ctx.app.push_toast( + texts::tui_hermes_memory_saved(super::config::hermes_memory_kind_label(kind)), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + Ok(()) +} + fn submit_prompt_create( ctx: &mut RuntimeActionContext<'_>, id: String, diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index efc45933..f224985a 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -42,6 +42,7 @@ pub(crate) fn import_mcp_for_current_app(app: &mut App, data: &mut UiData) -> Re AppType::Codex => McpService::import_from_codex(&state), AppType::Gemini => McpService::import_from_gemini(&state), AppType::OpenCode => McpService::import_from_opencode(&state), + AppType::Hermes => McpService::import_from_hermes(&state), AppType::OpenClaw => Ok(0), } }, @@ -68,6 +69,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Codex => "Codex", AppType::Gemini => "Gemini", AppType::OpenCode => "OpenCode", + AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mcp.rs b/src-tauri/src/cli/tui/runtime_actions/mcp.rs index e928d4ce..8f25c607 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mcp.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mcp.rs @@ -57,6 +57,7 @@ pub(super) fn set_apps( AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { let next_enabled = apps.is_enabled_for(&app_type); if before.is_enabled_for(&app_type) == next_enabled { diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index d7d2dffb..fa84e540 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -46,12 +46,27 @@ fn normalize_route_for_app(app_type: &AppType, route: &super::route::Route) -> s | super::route::Route::SettingsProxy => route.clone(), _ => super::route::Route::Main, }, + AppType::Hermes => match route { + super::route::Route::Main + | super::route::Route::Providers + | super::route::Route::ProviderDetail { .. } + | super::route::Route::Mcp + | super::route::Route::HermesMemory + | super::route::Route::Skills + | super::route::Route::SkillsDiscover + | super::route::Route::SkillsRepos + | super::route::Route::SkillDetail { .. } + | super::route::Route::Settings + | super::route::Route::SettingsProxy => route.clone(), + _ => super::route::Route::Main, + }, _ => match route { super::route::Route::ConfigOpenClawWorkspace | super::route::Route::ConfigOpenClawDailyMemory | super::route::Route::ConfigOpenClawEnv | super::route::Route::ConfigOpenClawTools | super::route::Route::ConfigOpenClawAgents => super::route::Route::Config, + super::route::Route::HermesMemory => super::route::Route::Main, _ => route.clone(), }, } @@ -305,6 +320,11 @@ pub(crate) fn handle_action( Action::OpenClawOpenDirectory { subdir } => { config::open_openclaw_directory(&mut ctx, subdir) } + Action::HermesMemoryOpen { kind } => config::open_hermes_memory(&mut ctx, kind), + Action::HermesMemorySetEnabled { kind, enabled } => { + config::set_hermes_memory_enabled(&mut ctx, kind, enabled) + } + Action::HermesOpenMemoryDirectory => config::open_hermes_memory_directory(&mut ctx), Action::ConfigReset => config::reset(&mut ctx), Action::SetSkipClaudeOnboarding { enabled } => { crate::settings::set_skip_claude_onboarding(enabled)?; @@ -592,6 +612,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -601,6 +622,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::OpenClaw)); @@ -660,6 +682,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -681,6 +704,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, @@ -708,6 +732,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -718,6 +743,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::Claude)); @@ -754,6 +780,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -772,6 +799,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, diff --git a/src-tauri/src/cli/tui/runtime_actions/providers.rs b/src-tauri/src/cli/tui/runtime_actions/providers.rs index a68a0893..5fc5198e 100644 --- a/src-tauri/src/cli/tui/runtime_actions/providers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/providers.rs @@ -77,6 +77,9 @@ pub(super) fn import_live_config(ctx: &mut RuntimeActionContext<'_>) -> Result<( crate::app_config::AppType::OpenClaw => { ProviderService::import_openclaw_providers_from_live(&state)? > 0 } + crate::app_config::AppType::Hermes => { + ProviderService::import_hermes_providers_from_live(&state)? > 0 + } _ => ProviderService::import_default_config(&state, ctx.app.app_type.clone())?, }; @@ -128,7 +131,7 @@ fn do_switch(ctx: &mut RuntimeActionContext<'_>, id: String) -> Result<(), AppEr }); ctx.app.overlay = proxy_overlay.unwrap_or(Overlay::None); - if matches!(ctx.app.app_type, crate::app_config::AppType::OpenCode) { + if ctx.app.app_type.is_additive_mode() { ctx.app.push_toast( texts::tui_toast_provider_added_to_app_config(ctx.app.app_type.as_str()), ToastKind::Success, @@ -325,7 +328,7 @@ pub(super) fn remove_from_config( *ctx.data = UiData::load(&ctx.app.app_type)?; Ok(()) } - crate::app_config::AppType::OpenCode => { + crate::app_config::AppType::OpenCode | crate::app_config::AppType::Hermes => { let state = load_state()?; ProviderService::remove_from_live_config(&state, ctx.app.app_type.clone(), &id)?; ctx.app.push_toast( @@ -344,6 +347,17 @@ pub(super) fn set_default_model( provider_id: String, model_id: String, ) -> Result<(), AppError> { + if matches!(ctx.app.app_type, crate::app_config::AppType::Hermes) { + let state = load_state()?; + ProviderService::switch(&state, crate::app_config::AppType::Hermes, &provider_id)?; + ctx.app.push_toast( + texts::tui_toast_provider_enabled(&provider_id), + ToastKind::Success, + ); + *ctx.data = UiData::load(&ctx.app.app_type)?; + return Ok(()); + } + if !matches!(ctx.app.app_type, crate::app_config::AppType::OpenClaw) { return Ok(()); } @@ -503,6 +517,9 @@ pub(super) fn model_fetch( error: None, selected_idx: 0, }; + if matches!(field, ProviderAddField::HermesModels) { + ctx.app.pending_overlay = Some(Overlay::HermesModelsPicker { editing: false }); + } if let Err(err) = tx.send(ModelFetchReq::Fetch { request_id, @@ -610,6 +627,14 @@ mod tests { update_settings(settings).expect("set openclaw override dir"); Self { previous } } + + fn with_hermes_dir(path: &Path) -> Self { + let previous = get_settings(); + let mut settings = AppSettings::default(); + settings.hermes_config_dir = Some(path.display().to_string()); + update_settings(settings).expect("set hermes override dir"); + Self { previous } + } } impl Drop for SettingsGuard { @@ -1425,6 +1450,111 @@ mod tests { ); } + #[test] + #[serial(home_settings)] + fn hermes_switch_adds_and_enables_provider_then_remove_keeps_it_visible_for_re_add() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = EnvGuard::set_home(temp_home.path()); + let hermes_dir = temp_home.path().join(".hermes"); + std::fs::create_dir_all(&hermes_dir).expect("create hermes dir"); + std::fs::write( + hermes_dir.join("config.yaml"), + "custom_providers: []\nmodel: {}\n", + ) + .expect("write hermes config"); + let _settings = SettingsGuard::with_hermes_dir(&hermes_dir); + + let mut config = MultiAppConfig::default(); + let manager = config + .get_manager_mut(&AppType::Hermes) + .expect("hermes manager"); + manager.providers.insert( + "p1".to_string(), + Provider::with_id( + "p1".to_string(), + "Hermes Provider".to_string(), + json!({ + "base_url": "https://hermes.example.com/v1", + "api_key": "sk-demo", + "models": [{"id": "main", "name": "Main"}] + }), + None, + ), + ); + config.save().expect("persist hermes provider"); + + let mut terminal = TuiTerminal::new_for_test().expect("create terminal"); + let mut app = App::new(Some(AppType::Hermes)); + let mut data = UiData::load(&AppType::Hermes).expect("load initial hermes data"); + assert_eq!(data.providers.current_id, ""); + assert!( + data.providers + .rows + .iter() + .any(|row| row.id == "p1" && !row.is_in_config && !row.is_current), + "precondition: saved provider should start outside Hermes config" + ); + let mut proxy_loading = RequestTracker::default(); + let mut webdav_loading = RequestTracker::default(); + let mut update_check = RequestTracker::default(); + let mut ctx = RuntimeActionContext { + terminal: &mut terminal, + app: &mut app, + data: &mut data, + speedtest_req_tx: None, + stream_check_req_tx: None, + skills_req_tx: None, + proxy_req_tx: None, + proxy_loading: &mut proxy_loading, + local_env_req_tx: None, + webdav_req_tx: None, + webdav_loading: &mut webdav_loading, + update_req_tx: None, + update_check: &mut update_check, + model_fetch_req_tx: None, + }; + + switch(&mut ctx, "p1".to_string()).expect("add and enable hermes provider"); + + assert_eq!(ctx.data.providers.current_id, "p1"); + assert_eq!( + crate::hermes_config::get_current_provider_id().expect("read hermes current"), + Some("p1".to_string()) + ); + assert!(crate::hermes_config::get_providers() + .expect("read hermes providers") + .contains_key("p1")); + assert!(ctx + .data + .providers + .rows + .iter() + .any(|row| row.id == "p1" && row.is_in_config && row.is_current)); + + remove_from_config(&mut ctx, "p1".to_string()).expect("remove hermes provider from config"); + + assert!(!crate::hermes_config::get_providers() + .expect("read hermes providers after remove") + .contains_key("p1")); + let removed_row = ctx + .data + .providers + .rows + .iter() + .find(|row| row.id == "p1") + .expect("removed provider should remain visible"); + assert!(!removed_row.is_in_config); + assert!(removed_row.is_saved); + assert_eq!( + removed_row + .provider + .meta + .as_ref() + .and_then(|meta| meta.live_config_managed), + Some(false) + ); + } + #[test] #[serial(home_settings)] fn provider_switch_existing_codex_install_with_current_provider_switches_normally() { diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index cee2266e..02eba816 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -588,6 +588,10 @@ fn model_fetch_strategy_matches_provider_field() { model_fetch_strategy_for_field(ProviderAddField::ClaudeModelConfig), ModelFetchStrategy::Anthropic ); + assert_eq!( + model_fetch_strategy_for_field(ProviderAddField::HermesModels), + ModelFetchStrategy::Bearer + ); } #[test] @@ -643,6 +647,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/text_edit.rs b/src-tauri/src/cli/tui/text_edit.rs index 3d0571a3..1ac95c7d 100644 --- a/src-tauri/src/cli/tui/text_edit.rs +++ b/src-tauri/src/cli/tui/text_edit.rs @@ -57,9 +57,10 @@ impl TextEditCommand { } } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default)] pub(crate) struct TextInputPolicy { pub max_chars: Option, + pub sanitize: Option Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -134,6 +135,9 @@ impl TextInput { TextEditCommand::DeleteToLineEnd => self.delete_to_line_end(), TextEditCommand::DeleteWordBackward => self.delete_word_backward(), TextEditCommand::Insert(c) => { + let Some(c) = policy.sanitize.map_or(Some(c), |sanitize| sanitize(c)) else { + return false; + }; if policy .max_chars .is_some_and(|max_chars| self.len_chars() >= max_chars) @@ -356,7 +360,10 @@ mod tests { let edit = input .apply_key_with_policy( KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), - TextInputPolicy { max_chars: Some(3) }, + TextInputPolicy { + max_chars: Some(3), + sanitize: None, + }, ) .expect("printable input should be handled"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index 69131619..e6e318ae 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -183,6 +183,7 @@ fn accent_rgb(app: &AppType) -> (u8, u8, u8) { AppType::Claude => DRACULA_CYAN, AppType::Gemini => DRACULA_PINK, AppType::OpenCode => DRACULA_ORANGE, + AppType::Hermes => DRACULA_YELLOW, AppType::OpenClaw => OPENCLAW_CORAL, } } diff --git a/src-tauri/src/cli/tui/ui.rs b/src-tauri/src/cli/tui/ui.rs index 4808edfb..158ffd64 100644 --- a/src-tauri/src/cli/tui/ui.rs +++ b/src-tauri/src/cli/tui/ui.rs @@ -140,6 +140,7 @@ fn render_content( } Route::Mcp => render_mcp(frame, app, data, content_area, theme), Route::Prompts => render_prompts(frame, app, data, content_area, theme), + Route::HermesMemory => render_hermes_memory(frame, app, data, content_area, theme), Route::Config => render_config(frame, app, data, content_area, theme), Route::ConfigOpenClawWorkspace | Route::ConfigOpenClawDailyMemory => { if matches!(app.app_type, AppType::OpenClaw) { diff --git a/src-tauri/src/cli/tui/ui/chrome.rs b/src-tauri/src/cli/tui/ui/chrome.rs index cb3f7cef..84fd9d7f 100644 --- a/src-tauri/src/cli/tui/ui/chrome.rs +++ b/src-tauri/src/cli/tui/ui/chrome.rs @@ -218,6 +218,7 @@ pub(super) fn nav_label(item: NavItem) -> &'static str { NavItem::Providers => texts::menu_manage_providers(), NavItem::Mcp => texts::menu_manage_mcp(), NavItem::Prompts => texts::menu_manage_prompts(), + NavItem::HermesMemory => texts::menu_hermes_memory(), NavItem::Config => texts::menu_manage_config(), NavItem::Skills => texts::menu_manage_skills(), NavItem::OpenClawWorkspace => texts::menu_openclaw_workspace(), @@ -235,6 +236,7 @@ pub(super) fn nav_label_variants(item: NavItem) -> (&'static str, &'static str) NavItem::Providers => texts::menu_manage_providers_variants(), NavItem::Mcp => texts::menu_manage_mcp_variants(), NavItem::Prompts => texts::menu_manage_prompts_variants(), + NavItem::HermesMemory => texts::menu_hermes_memory_variants(), NavItem::Config => texts::menu_manage_config_variants(), NavItem::Skills => texts::menu_manage_skills_variants(), NavItem::OpenClawWorkspace => texts::menu_openclaw_workspace_variants(), @@ -257,6 +259,7 @@ pub(super) fn nav_pane_width(theme: &super::theme::Theme) -> u16 { let max_text_width = NavItem::ALL .iter() .chain(NavItem::OPENCLAW_ALL.iter()) + .chain(NavItem::HERMES_ALL.iter()) .flat_map(|item| { let (en, zh) = nav_label_variants(*item); [en, zh] diff --git a/src-tauri/src/cli/tui/ui/config.rs b/src-tauri/src/cli/tui/ui/config.rs index 01a1d58f..18cbb8a5 100644 --- a/src-tauri/src/cli/tui/ui/config.rs +++ b/src-tauri/src/cli/tui/ui/config.rs @@ -2369,6 +2369,132 @@ fn render_openclaw_daily_memory( frame.render_stateful_widget(table, inset_left(chunks[2], CONTENT_INSET_LEFT), &mut state); } +pub(super) fn render_hermes_memory( + frame: &mut Frame<'_>, + app: &App, + data: &UiData, + area: Rect, + theme: &super::theme::Theme, +) { + let outer = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(pane_border_style(app, Focus::Content, theme)) + .title(texts::tui_hermes_memory_title()); + frame.render_widget(outer.clone(), area); + let inner = outer.inner(area); + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(inner); + + if app.focus == Focus::Content { + render_key_bar_center( + frame, + chunks[0], + theme, + &[ + ("Enter", texts::tui_key_edit()), + ("Space/x", texts::tui_key_toggle()), + ("o", texts::tui_key_open_directory()), + ], + ); + } + + frame.render_widget( + Paragraph::new(format!( + "{}: {}", + texts::tui_hermes_memory_directory_label(), + data.config.hermes_memory.directory_path.display() + )) + .wrap(Wrap { trim: false }), + inset_left(chunks[1], CONTENT_INSET_LEFT), + ); + + let rows = [ + crate::hermes_config::MemoryKind::Memory, + crate::hermes_config::MemoryKind::User, + ] + .into_iter() + .map(|kind| { + let content = data.config.hermes_memory.content(kind); + let current = content.chars().count(); + let limit = data.config.hermes_memory.limit(kind); + let enabled = data.config.hermes_memory.enabled(kind); + let status = if enabled { + texts::enabled() + } else { + texts::disabled() + }; + let preview = content + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(90) + .collect::(); + Row::new(vec![ + Cell::from(hermes_memory_display_name(kind)), + Cell::from(status), + Cell::from(format!("{current}/{limit}")), + Cell::from(if preview.trim().is_empty() { + texts::tui_na().to_string() + } else { + preview + }), + ]) + }); + + let table = Table::new( + rows, + [ + Constraint::Length(18), + Constraint::Length(12), + Constraint::Length(14), + Constraint::Min(10), + ], + ) + .header( + Row::new(vec![ + Cell::from(texts::tui_hermes_memory_file_label()), + Cell::from(texts::tui_hermes_memory_status_label()), + Cell::from(texts::tui_hermes_memory_usage_label()), + Cell::from(texts::tui_hermes_memory_preview_label()), + ]) + .style(Style::default().fg(theme.comment)), + ) + .block(Block::default().borders(Borders::NONE)) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + + let mut state = TableState::default(); + state.select(Some(app.hermes_memory_idx)); + frame.render_stateful_widget(table, inset_left(chunks[2], CONTENT_INSET_LEFT), &mut state); +} + +fn hermes_memory_display_name(kind: crate::hermes_config::MemoryKind) -> String { + match kind { + crate::hermes_config::MemoryKind::Memory => { + format!( + "{} ({})", + texts::tui_hermes_memory_agent_tab(), + kind.filename() + ) + } + crate::hermes_config::MemoryKind::User => { + format!( + "{} ({})", + texts::tui_hermes_memory_user_tab(), + kind.filename() + ) + } + } +} + pub(super) fn render_settings( frame: &mut Frame<'_>, app: &App, diff --git a/src-tauri/src/cli/tui/ui/forms/mcp.rs b/src-tauri/src/cli/tui/ui/forms/mcp.rs index 11556935..780a3987 100644 --- a/src-tauri/src/cli/tui/ui/forms/mcp.rs +++ b/src-tauri/src/cli/tui/ui/forms/mcp.rs @@ -186,6 +186,7 @@ pub(crate) fn mcp_field_label_and_value( McpAddField::AppCodex => texts::tui_label_app_codex().to_string(), McpAddField::AppGemini => texts::tui_label_app_gemini().to_string(), McpAddField::AppOpenCode => texts::tui_label_app_opencode().to_string(), + McpAddField::AppHermes => texts::tui_label_app_hermes().to_string(), }; let value = match field { @@ -219,6 +220,13 @@ pub(crate) fn mcp_field_label_and_value( "[ ]".to_string() } } + McpAddField::AppHermes => { + if mcp.apps.hermes { + format!("[{}]", texts::tui_marker_active()) + } else { + "[ ]".to_string() + } + } _ => mcp .input(field) .map(|v| v.value.trim().to_string()) @@ -251,6 +259,7 @@ pub(crate) fn mcp_field_editor_line( McpAddField::AppCodex => format!("codex = {}", mcp.apps.codex), McpAddField::AppGemini => format!("gemini = {}", mcp.apps.gemini), McpAddField::AppOpenCode => format!("opencode = {}", mcp.apps.opencode), + McpAddField::AppHermes => format!("hermes = {}", mcp.apps.hermes), _ => String::new(), }; @@ -286,7 +295,8 @@ fn mcp_add_form_key_items( McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode, + | McpAddField::AppOpenCode + | McpAddField::AppHermes, ) => texts::tui_key_toggle(), _ => texts::tui_key_edit_mode(), }; @@ -299,7 +309,8 @@ fn mcp_add_form_key_items( McpAddField::AppClaude | McpAddField::AppCodex | McpAddField::AppGemini - | McpAddField::AppOpenCode, + | McpAddField::AppOpenCode + | McpAddField::AppHermes, ) => { keys.push(("Space", texts::tui_key_toggle())); } diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index 84c95b74..ef241789 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -24,7 +24,7 @@ fn common_json_preview_value(app_type: &AppType, common_snippet: &str) -> Option AppType::Gemini => serde_json::from_str::(common_snippet) .ok() .map(|env| json!({ "env": env })), - AppType::Codex | AppType::OpenCode | AppType::OpenClaw => None, + AppType::Codex | AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => None, } .filter(Value::is_object) } @@ -248,7 +248,7 @@ pub(crate) fn render_provider_add_form( fields .iter() .zip(rows_data.iter()) - .filter(|(field, _row)| !matches!(field, ProviderAddField::CommonConfigDivider)) + .filter(|(field, _row)| !provider_field_is_divider(**field)) .map(|(_field, (label, _value))| label.as_str()) .chain(std::iter::once(texts::tui_header_field())), 1, @@ -264,10 +264,7 @@ pub(crate) fn render_provider_add_form( .iter() .zip(rows_data.iter()) .map(|(field, (label, value))| { - if matches!( - field, - ProviderAddField::CommonConfigDivider | ProviderAddField::UsageQueryDivider - ) { + if provider_field_is_divider(*field) { let dashes_left = "┄".repeat(40); let dashes_right = "┄".repeat(200); Row::new(vec![ @@ -833,6 +830,9 @@ pub(crate) fn provider_field_label_and_value( field: ProviderAddField, ) -> (String, String) { let label = match field { + ProviderAddField::Id if provider.app_type == AppType::Hermes => { + texts::tui_label_hermes_provider_key().to_string() + } ProviderAddField::Id => texts::tui_label_id().to_string(), ProviderAddField::Name => texts::header_name().to_string(), ProviderAddField::WebsiteUrl => { @@ -880,6 +880,14 @@ pub(crate) fn provider_field_label_and_value( ProviderAddField::OpenCodeModelName => texts::tui_label_opencode_model_name().to_string(), ProviderAddField::OpenCodeModelContextLimit => texts::tui_label_context_limit().to_string(), ProviderAddField::OpenCodeModelOutputLimit => texts::tui_label_output_limit().to_string(), + ProviderAddField::HermesApiMode => texts::tui_label_hermes_api_mode().to_string(), + ProviderAddField::HermesApiKey => texts::tui_label_api_key().to_string(), + ProviderAddField::HermesBaseUrl => texts::tui_label_hermes_base_url().to_string(), + ProviderAddField::HermesModels => texts::tui_label_hermes_models().to_string(), + ProviderAddField::HermesRateLimitDelay => { + texts::tui_label_hermes_rate_limit_delay().to_string() + } + ProviderAddField::HermesAdvancedDivider => "- - - - - - - - -".to_string(), ProviderAddField::CommonConfigDivider => "- - - - - - - - -".to_string(), ProviderAddField::CommonSnippet => texts::tui_config_item_common_snippet().to_string(), ProviderAddField::IncludeCommonConfig => texts::tui_form_attach_common_config().to_string(), @@ -929,6 +937,12 @@ pub(crate) fn provider_field_label_and_value( } } ProviderAddField::OpenClawModels => provider.openclaw_models_summary(), + ProviderAddField::HermesApiMode => { + texts::tui_hermes_api_mode_value(provider.hermes_api_mode_value()).to_string() + } + ProviderAddField::HermesModels => provider.hermes_models_summary(), + ProviderAddField::HermesRateLimitDelay => provider.hermes_rate_limit_delay.value.clone(), + ProviderAddField::HermesAdvancedDivider => "- - - - - - - - - -".to_string(), ProviderAddField::CommonConfigDivider => "- - - - - - - - - -".to_string(), ProviderAddField::CommonSnippet => texts::tui_key_open().to_string(), ProviderAddField::UsageQueryDivider => String::new(), @@ -971,6 +985,7 @@ pub(crate) fn provider_field_editor_line( | ProviderAddField::CodexApiKey | ProviderAddField::GeminiApiKey | ProviderAddField::OpenCodeApiKey + | ProviderAddField::HermesApiKey ) { input.value.clone() } else { @@ -1019,8 +1034,22 @@ pub(crate) fn provider_field_editor_line( format!("send_user_agent = {}", provider.openclaw_user_agent) } ProviderAddField::OpenClawModels => texts::tui_openclaw_models_open_hint().to_string(), + ProviderAddField::HermesApiMode => { + format!("api_mode = {}", provider.hermes_api_mode_value()) + } + ProviderAddField::HermesModels => texts::tui_hermes_models_open_hint().to_string(), + ProviderAddField::HermesAdvancedDivider => String::new(), _ => String::new(), }; (Line::raw(text), 0) } } + +fn provider_field_is_divider(field: ProviderAddField) -> bool { + matches!( + field, + ProviderAddField::HermesAdvancedDivider + | ProviderAddField::CommonConfigDivider + | ProviderAddField::UsageQueryDivider + ) +} diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index f00cea31..a47164a4 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -42,13 +42,15 @@ pub(crate) fn add_form_key_items( ProviderAddField::ClaudeModelConfig | ProviderAddField::CommonSnippet | ProviderAddField::UsageQuery - | ProviderAddField::OpenClawModels, + | ProviderAddField::OpenClawModels + | ProviderAddField::HermesModels, ) => texts::tui_key_open(), Some( ProviderAddField::GeminiAuthType | ProviderAddField::ClaudeHideAttribution | ProviderAddField::OpenClawApiProtocol - | ProviderAddField::OpenClawUserAgent, + | ProviderAddField::OpenClawUserAgent + | ProviderAddField::HermesApiMode, ) => texts::tui_key_toggle(), _ => texts::tui_key_edit_mode(), }; @@ -236,7 +238,16 @@ pub(crate) fn render_form_json_preview_with_highlights( .enumerate() .map(|(idx, s)| { if highlighted_lines.contains(&idx) { - Line::from(Span::styled(s.to_string(), highlight_style)) + match s.find(|ch: char| !ch.is_whitespace()) { + Some(start) => { + let (indent, content) = s.split_at(start); + Line::from(vec![ + Span::raw(indent.to_string()), + Span::styled(content.to_string(), highlight_style), + ]) + } + None => Line::raw(s.to_string()), + } } else { Line::raw(s.to_string()) } diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 60c5685b..c40ef21b 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -194,6 +194,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); @@ -233,6 +234,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); diff --git a/src-tauri/src/cli/tui/ui/mcp.rs b/src-tauri/src/cli/tui/ui/mcp.rs index 99fea8cc..696ba5c2 100644 --- a/src-tauri/src/cli/tui/ui/mcp.rs +++ b/src-tauri/src/cli/tui/ui/mcp.rs @@ -29,6 +29,7 @@ pub(super) fn render_mcp( Cell::from(crate::app_config::AppType::Codex.as_str()), Cell::from(crate::app_config::AppType::Gemini.as_str()), Cell::from(crate::app_config::AppType::OpenCode.as_str()), + Cell::from(crate::app_config::AppType::Hermes.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -55,6 +56,11 @@ pub(super) fn render_mcp( } else { texts::tui_marker_inactive() }), + Cell::from(if row.server.apps.hermes { + texts::tui_marker_active() + } else { + texts::tui_marker_inactive() + }), ]) }); @@ -112,6 +118,11 @@ pub(super) fn render_mcp( .iter() .filter(|row| row.server.apps.opencode) .count(), + data.mcp + .rows + .iter() + .filter(|row| row.server.apps.hermes) + .count(), ); render_summary_bar(frame, chunks[1], theme, summary); @@ -123,6 +134,7 @@ pub(super) fn render_mcp( Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), + Constraint::Length(8), ], ) .header(header) diff --git a/src-tauri/src/cli/tui/ui/overlay/basic.rs b/src-tauri/src/cli/tui/ui/overlay/basic.rs index d2343d0e..40e83276 100644 --- a/src-tauri/src/cli/tui/ui/overlay/basic.rs +++ b/src-tauri/src/cli/tui/ui/overlay/basic.rs @@ -1,7 +1,12 @@ use super::super::theme; use super::super::*; -pub(super) fn render_help_overlay(frame: &mut Frame<'_>, content_area: Rect, theme: &theme::Theme) { +pub(super) fn render_help_overlay( + frame: &mut Frame<'_>, + app: &App, + content_area: Rect, + theme: &theme::Theme, +) { let area = centered_rect(OVERLAY_LG.0, OVERLAY_LG.1, content_area); frame.render_widget(Clear, area); @@ -21,7 +26,7 @@ pub(super) fn render_help_overlay(frame: &mut Frame<'_>, content_area: Rect, the render_key_bar_center(frame, chunks[0], theme, &[("Esc", texts::tui_key_close())]); let body_area = inset_top(chunks[1], 1); - let lines = texts::tui_help_text() + let lines = texts::tui_help_text_for_app(&app.app_type) .lines() .map(|s| Line::raw(s.to_string())) .collect::>(); diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 2d41a25e..0db3450f 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -1,5 +1,6 @@ use super::super::theme; use super::super::*; +use crate::cli::tui::form::{HermesModelField, ProviderAddFormState}; use crate::cli::tui::text_edit::TextInput; pub(super) fn render_claude_model_picker_overlay( @@ -353,6 +354,222 @@ pub(super) fn render_provider_test_menu_overlay( frame.render_stateful_widget(list, body_area, &mut state); } +pub(super) fn render_hermes_models_picker_overlay( + frame: &mut Frame<'_>, + app: &App, + content_area: Rect, + theme: &theme::Theme, + editing: bool, +) { + let area = centered_rect_fixed(86, 24, content_area); + frame.render_widget(Clear, area); + + let Some(FormState::ProviderAdd(provider)) = app.form.as_ref() else { + return; + }; + + let outer = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(overlay_border_style(theme, false)) + .title(texts::tui_hermes_models_title(provider.name.value.trim())); + frame.render_widget(outer.clone(), area); + let inner = outer.inner(area); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Min(0), + Constraint::Length(2), + Constraint::Length(3), + ]) + .split(inner); + + let key_items: Vec<(&str, &str)> = if editing { + vec![ + ("←→/Home/End", texts::tui_key_move()), + ("Enter/Esc", texts::tui_key_exit_edit()), + ] + } else { + vec![ + ("↑↓", texts::tui_key_select()), + ("Enter", texts::tui_key_edit()), + ("f", texts::tui_key_fetch_model()), + ("a", texts::tui_key_add()), + ("d", texts::tui_key_delete()), + ("Esc", texts::tui_key_close()), + ] + }; + render_key_bar_center(frame, chunks[0], theme, &key_items); + + let fields = provider.hermes_model_fields(); + if fields.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled( + texts::tui_hermes_models_no_models(), + Style::default().fg(theme.dim), + )) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + inset_top(chunks[1], 1), + ); + } else { + let rows_data = fields + .iter() + .map(|field| hermes_model_field_label_and_value(provider, *field)) + .collect::>(); + let label_col_width = field_label_column_width( + rows_data + .iter() + .map(|(label, _)| label.as_str()) + .chain(std::iter::once(texts::tui_header_field())), + 1, + ); + + let header = Row::new(vec![ + Cell::from(cell_pad(texts::tui_header_field())), + Cell::from(texts::tui_header_value()), + ]) + .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); + let mut rows = Vec::with_capacity(rows_data.len() + provider.hermes_models.len()); + for (idx, (label, value)) in rows_data.iter().enumerate() { + if idx > 0 && idx % 3 == 0 { + rows.push( + Row::new(vec![ + Cell::from(cell_pad(&"┄".repeat(40))), + Cell::from("┄".repeat(200)), + ]) + .style(Style::default().fg(theme.dim)), + ); + } + rows.push(Row::new(vec![ + Cell::from(cell_pad(label)), + Cell::from(value.clone()), + ])); + } + let table = Table::new( + rows, + [Constraint::Length(label_col_width), Constraint::Min(10)], + ) + .header(header) + .block( + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .title(texts::tui_label_hermes_models()), + ) + .row_highlight_style(selection_style(theme)) + .highlight_symbol(highlight_symbol(theme)); + + let mut state = TableState::default(); + state.select(Some(hermes_model_visual_row_index( + provider.hermes_models_field_idx.min(fields.len() - 1), + ))); + frame.render_stateful_widget(table, chunks[1], &mut state); + } + + let footer = if provider.hermes_models.is_empty() { + texts::tui_hermes_models_fetch_hint() + } else { + texts::tui_hermes_models_hint() + }; + frame.render_widget( + Paragraph::new(Line::styled(footer, Style::default().fg(theme.dim))) + .wrap(Wrap { trim: true }), + chunks[2], + ); + + render_hermes_model_picker_input(frame, provider, chunks[3], theme, editing); +} + +fn hermes_model_visual_row_index(field_idx: usize) -> usize { + field_idx + field_idx / 3 +} + +fn hermes_model_field_label_and_value( + provider: &ProviderAddFormState, + field: HermesModelField, +) -> (String, String) { + match field { + HermesModelField::Id(index) => ( + texts::tui_hermes_model_id_label(index + 1), + hermes_model_string(provider, index, "id"), + ), + HermesModelField::Name(index) => ( + texts::tui_hermes_model_name_label(index + 1), + hermes_model_string(provider, index, "name"), + ), + HermesModelField::ContextLength(index) => ( + texts::tui_hermes_model_context_length_label(index + 1), + hermes_model_string(provider, index, "context_length"), + ), + } +} + +fn hermes_model_string(provider: &ProviderAddFormState, index: usize, key: &str) -> String { + provider + .hermes_models + .get(index) + .and_then(|model| model.get(key)) + .and_then(|value| { + value + .as_str() + .map(str::to_string) + .or_else(|| value.as_i64().map(|number| number.to_string())) + .or_else(|| value.as_u64().map(|number| number.to_string())) + }) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| texts::tui_na().to_string()) +} + +fn render_hermes_model_picker_input( + frame: &mut Frame<'_>, + provider: &ProviderAddFormState, + area: Rect, + theme: &theme::Theme, + editing: bool, +) { + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Plain) + .border_style(if editing { + Style::default() + .fg(theme.accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.dim) + }) + .title(if editing { + texts::tui_form_editing_title() + } else { + texts::tui_form_input_title() + }); + frame.render_widget(block.clone(), area); + let inner = block.inner(area); + + if provider.selected_hermes_model_field().is_none() { + frame.render_widget( + Paragraph::new(Line::raw(texts::tui_hermes_models_add_hint())) + .wrap(Wrap { trim: false }), + inner, + ); + return; + } + + let input = &provider.hermes_model_input; + let (visible, cursor_x) = visible_text_window(&input.value, input.cursor, inner.width as usize); + frame.render_widget( + Paragraph::new(Line::raw(visible)).wrap(Wrap { trim: false }), + inner, + ); + + if editing { + let x = inner.x + cursor_x.min(inner.width.saturating_sub(1)); + frame.set_cursor_position((x, inner.y)); + } +} + pub(super) fn render_model_fetch_picker_overlay( frame: &mut Frame<'_>, content_area: Rect, @@ -773,6 +990,7 @@ pub(super) fn render_mcp_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } @@ -874,6 +1092,7 @@ pub(super) fn render_skills_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } diff --git a/src-tauri/src/cli/tui/ui/overlay/render.rs b/src-tauri/src/cli/tui/ui/overlay/render.rs index 175b572b..90a03261 100644 --- a/src-tauri/src/cli/tui/ui/overlay/render.rs +++ b/src-tauri/src/cli/tui/ui/overlay/render.rs @@ -11,7 +11,7 @@ pub(crate) fn render_overlay( match &app.overlay { Overlay::None => {} - Overlay::Help => super::basic::render_help_overlay(frame, content_area, theme), + Overlay::Help => super::basic::render_help_overlay(frame, app, content_area, theme), Overlay::Confirm(confirm) => { super::basic::render_confirm_overlay(frame, content_area, theme, confirm) } @@ -87,6 +87,15 @@ pub(crate) fn render_overlay( *selected, ) } + Overlay::HermesModelsPicker { editing } => { + super::pickers::render_hermes_models_picker_overlay( + frame, + app, + content_area, + theme, + *editing, + ) + } Overlay::ModelFetchPicker { input, query, diff --git a/src-tauri/src/cli/tui/ui/providers.rs b/src-tauri/src/cli/tui/ui/providers.rs index 2796df5c..a0df4608 100644 --- a/src-tauri/src/cli/tui/ui/providers.rs +++ b/src-tauri/src/cli/tui/ui/providers.rs @@ -25,6 +25,32 @@ fn opencode_status_label(row: &ProviderRow) -> &'static str { } } +fn additive_status_label(app_type: &AppType, row: &ProviderRow) -> &'static str { + if matches!(app_type, AppType::Hermes) && row.is_default_model { + texts::tui_provider_status_in_use() + } else if row.is_default_model { + texts::tui_openclaw_status_default() + } else { + opencode_status_label(row) + } +} + +fn provider_switch_key_label(app_type: &AppType) -> &'static str { + if app_type.is_additive_mode() { + texts::tui_key_add_remove() + } else { + texts::tui_key_switch() + } +} + +fn provider_default_key_label(app_type: &AppType) -> &'static str { + if matches!(app_type, AppType::Hermes) { + texts::tui_key_enable() + } else { + texts::tui_key_set_default() + } +} + fn failover_queue_label(data: &UiData, provider_id: &str) -> String { failover_queue_position(data, provider_id) .map(|position| format!("#{position}")) @@ -153,13 +179,15 @@ pub(super) fn render_providers( keys.push(("a", texts::tui_key_add())); } else { keys.push(("Enter", texts::tui_key_details())); - keys.push(("Space", texts::tui_key_switch())); - keys.extend([ - ("a", texts::tui_key_add()), - ("e", texts::tui_key_edit()), - ("d", texts::tui_key_delete()), - ("t", texts::tui_key_test()), - ]); + keys.push(("Space", provider_switch_key_label(&app.app_type))); + keys.push(("a", texts::tui_key_add())); + if let Some(row) = visible.get(app.provider_idx) { + if !data::provider_is_read_only(&app.app_type, row) { + keys.push(("e", texts::tui_key_edit())); + keys.push(("d", texts::tui_key_delete())); + } + } + keys.push(("t", texts::tui_key_test())); if selected_supports_quota { keys.push(("r", texts::tui_key_refresh())); } @@ -170,9 +198,8 @@ pub(super) fn render_providers( keys.push(("f", texts::tui_key_failover())); } if let Some(row) = visible.get(app.provider_idx) { - if matches!(app.app_type, crate::app_config::AppType::OpenClaw) && row.is_in_config - { - keys.push(("x", texts::tui_key_set_default())); + if matches!(app.app_type, AppType::OpenClaw | AppType::Hermes) && row.is_in_config { + keys.push(("x", provider_default_key_label(&app.app_type))); } } } @@ -195,7 +222,7 @@ pub(super) fn render_providers( let rows = visible.iter().enumerate().map(|(idx, row)| { let marker = if failover_supported && data.proxy.auto_failover_enabled { failover_queue_label(data, &row.id) - } else if matches!(app.app_type, crate::app_config::AppType::OpenClaw) { + } else if matches!(app.app_type, AppType::OpenClaw | AppType::Hermes) { if row.is_default_model { "*".to_string() } else if row.is_in_config { @@ -203,7 +230,7 @@ pub(super) fn render_providers( } else { String::new() } - } else if matches!(app.app_type, crate::app_config::AppType::OpenCode) { + } else if matches!(app.app_type, AppType::OpenCode) { if row.is_in_config { "+".to_string() } else { @@ -281,16 +308,16 @@ pub(super) fn render_provider_detail( .split(inner); if app.focus == Focus::Content { - let mut keys = vec![ - ("Space", texts::tui_key_switch()), - ("e", texts::tui_key_edit()), - ]; + let mut keys = vec![("Space", provider_switch_key_label(&app.app_type))]; + if !data::provider_is_read_only(&app.app_type, row) { + keys.push(("e", texts::tui_key_edit())); + } keys.push(("t", texts::tui_key_test())); if data::quota_target_for_provider(&app.app_type, row).is_some() { keys.push(("r", texts::tui_key_refresh())); } - if matches!(app.app_type, crate::app_config::AppType::OpenClaw) && row.is_in_config { - keys.push(("x", texts::tui_key_set_default())); + if matches!(app.app_type, AppType::OpenClaw | AppType::Hermes) && row.is_in_config { + keys.push(("x", provider_default_key_label(&app.app_type))); } if crate::cli::tui::app::supports_temporary_provider_launch(&app.app_type) { keys.push(("o", texts::tui_key_launch_temp())); @@ -352,7 +379,7 @@ pub(super) fn render_provider_detail( } } - if matches!(app.app_type, crate::app_config::AppType::OpenCode) { + if matches!(app.app_type, AppType::OpenCode | AppType::Hermes) { lines.push(Line::raw("")); lines.push(Line::from(vec![ Span::styled( @@ -360,7 +387,7 @@ pub(super) fn render_provider_detail( Style::default().fg(theme.accent), ), Span::raw(": "), - Span::raw(opencode_status_label(row)), + Span::raw(additive_status_label(&app.app_type, row)), ])); } diff --git a/src-tauri/src/cli/tui/ui/skills/helpers.rs b/src-tauri/src/cli/tui/ui/skills/helpers.rs index 6d27a757..408aee25 100644 --- a/src-tauri/src/cli/tui/ui/skills/helpers.rs +++ b/src-tauri/src/cli/tui/ui/skills/helpers.rs @@ -42,6 +42,9 @@ pub(super) fn enabled_skill_apps_text(apps: &crate::app_config::SkillApps) -> St if apps.opencode { enabled.push("OpenCode"); } + if apps.hermes { + enabled.push("Hermes"); + } if enabled.is_empty() { texts::none().to_string() diff --git a/src-tauri/src/cli/tui/ui/skills/installed.rs b/src-tauri/src/cli/tui/ui/skills/installed.rs index b167a8f1..37024e97 100644 --- a/src-tauri/src/cli/tui/ui/skills/installed.rs +++ b/src-tauri/src/cli/tui/ui/skills/installed.rs @@ -50,6 +50,7 @@ pub(super) fn render_skills_installed( Cell::from(crate::app_config::AppType::Codex.as_str()), Cell::from(crate::app_config::AppType::Gemini.as_str()), Cell::from(crate::app_config::AppType::OpenCode.as_str()), + Cell::from(crate::app_config::AppType::Hermes.as_str()), ]) .style(Style::default().fg(theme.dim).add_modifier(Modifier::BOLD)); @@ -60,6 +61,7 @@ pub(super) fn render_skills_installed( Cell::from(skill_marker(skill.apps.codex)), Cell::from(skill_marker(skill.apps.gemini)), Cell::from(skill_marker(skill.apps.opencode)), + Cell::from(skill_marker(skill.apps.hermes)), ]) }); @@ -71,6 +73,7 @@ pub(super) fn render_skills_installed( Constraint::Length(8), Constraint::Length(8), Constraint::Length(10), + Constraint::Length(8), ], ) .header(header) @@ -108,12 +111,19 @@ fn installed_summary(data: &UiData) -> String { .iter() .filter(|s| s.apps.opencode) .count(); + let enabled_hermes = data + .skills + .installed + .iter() + .filter(|s| s.apps.hermes) + .count(); texts::tui_skills_installed_counts( enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, + enabled_hermes, ) } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 9d8fbd4d..ba046ddd 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -160,6 +160,58 @@ fn provider_field_label_and_value_renders_claude_hide_attribution_toggle() { assert_eq!(value, "[✓]"); } +#[test] +fn provider_field_label_and_value_renders_na_for_blank_hermes_rate_limit_delay() { + let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Hermes); + + let (label, value) = super::provider_field_label_and_value( + &form, + crate::cli::tui::form::ProviderAddField::HermesRateLimitDelay, + ); + assert_eq!(label, texts::tui_label_hermes_rate_limit_delay()); + assert_eq!(value, texts::tui_na()); + + form.hermes_rate_limit_delay.set("0.5"); + let (_label, value) = super::provider_field_label_and_value( + &form, + crate::cli::tui::form::ProviderAddField::HermesRateLimitDelay, + ); + assert_eq!(value, "0.5"); +} + +#[test] +fn provider_form_fields_show_dashed_divider_before_hermes_rate_limit_delay() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + app.form = Some(crate::cli::tui::form::FormState::ProviderAdd( + crate::cli::tui::form::ProviderAddFormState::new(AppType::Hermes), + )); + + let data = minimal_data(&app.app_type); + let buf = render(&app, &data); + + let mut rate_limit_y = None; + for y in 0..buf.area.height { + let line = line_at(&buf, y); + if line.contains("Rate limit") || line.contains("请求间隔") { + rate_limit_y = Some(y); + break; + } + } + + let rate_limit_y = + rate_limit_y.expect("Hermes rate limit delay row missing from provider form"); + let above = line_at(&buf, rate_limit_y.saturating_sub(1)); + assert!( + above.contains("┄┄┄"), + "expected dashed divider row above Hermes rate limit delay, got: {above}" + ); +} + #[test] fn provider_form_renders_usage_query_entry_as_open_row() { let _lock = lock_env(); @@ -387,6 +439,24 @@ pub(super) fn line_at(buf: &Buffer, y: u16) -> String { out } +fn cell_column_of(buf: &Buffer, y: u16, needle: &str) -> Option { + let cells = needle.chars().map(|ch| ch.to_string()).collect::>(); + if cells.is_empty() { + return Some(0); + } + + for x in 0..buf.area.width { + if cells.iter().enumerate().all(|(offset, symbol)| { + let cell_x = x.saturating_add(offset as u16); + cell_x < buf.area.width && buf[(cell_x, y)].symbol() == symbol + }) { + return Some(x); + } + } + + None +} + fn all_text(buf: &Buffer) -> String { let mut all = String::new(); for y in 0..buf.area.height { @@ -622,6 +692,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 1, } @@ -693,6 +764,36 @@ fn provider_form_fields_show_dashed_divider_before_common_snippet() { ); } +#[test] +fn hermes_models_overlay_separates_models_with_dashed_divider() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + + let mut form = crate::cli::tui::form::ProviderAddFormState::new(AppType::Hermes); + form.focus = FormFocus::Fields; + form.hermes_models = vec![ + json!({ "id": "model-a", "name": "Model A" }), + json!({ "id": "model-b", "name": "Model B" }), + ]; + form.open_hermes_models_picker(); + app.form = Some(FormState::ProviderAdd(form)); + app.overlay = Overlay::HermesModelsPicker { editing: false }; + + let content = all_text(&render(&app, &minimal_data(&app.app_type))); + let first_model = line_index(&content, &buffer_cell_text("model-a")); + let divider = line_index(&content, "┄┄┄"); + let second_model = line_index(&content, &buffer_cell_text("model-b")); + + assert!( + first_model < divider && divider < second_model, + "expected dashed divider between Hermes models, got:\n{content}" + ); +} + #[test] fn provider_form_json_preview_highlights_common_config_lines() { let _lock = lock_env(); @@ -718,19 +819,21 @@ fn provider_form_json_preview_highlights_common_config_lines() { let buf = render(&app, &data); let theme = theme_for(&app.app_type); let mut common_bg = None; + let mut common_indent_bg = None; let mut provider_key_bg = None; for y in 0..buf.area.height { - let line = line_at(&buf, y); - if let Some(x) = line.find("\"COMMON_FLAG\"") { - common_bg = Some(buf[(x as u16, y)].bg); + if let Some(x) = cell_column_of(&buf, y, "\"COMMON_FLAG\"") { + common_bg = Some(buf[(x, y)].bg); + common_indent_bg = x.checked_sub(1).map(|indent_x| buf[(indent_x, y)].bg); } - if let Some(x) = line.find("\"ANTHROPIC_AUTH_TOKEN\"") { - provider_key_bg = Some(buf[(x as u16, y)].bg); + if let Some(x) = cell_column_of(&buf, y, "\"ANTHROPIC_AUTH_TOKEN\"") { + provider_key_bg = Some(buf[(x, y)].bg); } } assert_eq!(common_bg, Some(theme.surface)); + assert_ne!(common_indent_bg, Some(theme.surface)); assert_ne!(provider_key_bg, Some(theme.surface)); } @@ -845,6 +948,7 @@ fn header_only_renders_selected_visible_apps() { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -873,6 +977,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -901,6 +1006,7 @@ fn settings_page_shows_visible_apps_row_value() { codex: false, gemini: true, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -981,6 +1087,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }; @@ -1017,6 +1124,7 @@ fn visible_apps_picker_uses_space_toggle_key() { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }; @@ -1634,6 +1742,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 0, }, @@ -2360,12 +2469,13 @@ fn skills_page_renders_sync_method_and_installed_rows() { let buf = render(&app, &data); let all = all_text(&buf); - assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0))); + assert!(all.contains(&texts::tui_skills_installed_counts(1, 0, 0, 0, 0))); assert!(!all.contains(texts::tui_header_directory())); assert!(all.contains(AppType::Claude.as_str())); assert!(all.contains(AppType::Codex.as_str())); assert!(all.contains(AppType::Gemini.as_str())); assert!(all.contains(AppType::OpenCode.as_str())); + assert!(all.contains(AppType::Hermes.as_str())); assert!(!all.contains("hello-skill")); assert!(all.contains("Hello Skill")); } @@ -2386,6 +2496,7 @@ fn skills_page_empty_state_keeps_mcp_style_table() { assert!(all.contains(texts::header_name())); assert!(all.contains(AppType::Claude.as_str())); assert!(all.contains(AppType::OpenCode.as_str())); + assert!(all.contains(AppType::Hermes.as_str())); assert!(!all.contains(texts::tui_skills_empty_title())); assert!(!all.contains(texts::tui_skills_empty_subtitle())); } @@ -2444,6 +2555,7 @@ fn skills_page_shows_opencode_summary() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; @@ -2453,6 +2565,33 @@ fn skills_page_shows_opencode_summary() { assert!(all.contains("OpenCode: 1")); } +#[test] +fn skills_page_shows_hermes_column_and_summary() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Skills; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + let mut skill = installed_skill("hello-skill", "Hello Skill"); + skill.apps = SkillApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }; + data.skills.installed = vec![skill]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains(AppType::Hermes.as_str())); + assert!(all.contains("Hermes: 1")); +} + #[test] fn skill_detail_page_shows_opencode_enabled_state() { let _lock = lock_env(); @@ -2471,6 +2610,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; @@ -2482,6 +2622,36 @@ fn skill_detail_page_shows_opencode_enabled_state() { assert!(!all.contains("opencode=true")); } +#[test] +fn skill_detail_page_shows_hermes_enabled_state() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::SkillDetail { + directory: "hello-skill".to_string(), + }; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + let mut skill = installed_skill("hello-skill", "Hello Skill"); + skill.apps = SkillApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }; + data.skills.installed = vec![skill]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains(texts::tui_label_enabled_for())); + assert!(all.contains("Hermes")); + assert!(!all.contains("hermes=true")); +} + #[test] fn skills_import_overlay_uses_friendly_copy() { let _lock = lock_env(); @@ -2547,6 +2717,43 @@ fn mcp_page_renders_opencode_column() { assert!(all.contains("opencode")); } +#[test] +fn mcp_page_renders_hermes_column() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Mcp; + app.focus = Focus::Content; + + let mut data = minimal_data(&app.app_type); + data.mcp.rows = vec![super::super::data::McpRow { + id: "m1".to_string(), + server: crate::app_config::McpServer { + id: "m1".to_string(), + name: "Server".to_string(), + server: json!({}), + apps: crate::app_config::McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: vec![], + }, + }]; + + let buf = render(&app, &data); + let all = all_text(&buf); + + assert!(all.contains("hermes")); + assert!(all.contains("Hermes: 1")); +} + #[test] fn mcp_page_key_bar_hides_validate_action() { let _lock = lock_env(); @@ -7158,7 +7365,7 @@ fn openclaw_provider_list_key_bar_uses_common_provider_actions() { all.push('\n'); } - assert!(all.contains("Space switch"), "{all}"); + assert!(all.contains("Space add/remove"), "{all}"); assert!(all.contains("t test"), "{all}"); assert!(all.contains("x set default"), "{all}"); assert!(!all.contains("s add/remove"), "{all}"); @@ -7297,7 +7504,7 @@ fn opencode_provider_list_key_bar_uses_config_membership_actions() { let all = all_text(&render(&app, &data)); - assert!(all.contains("Space switch"), "{all}"); + assert!(all.contains("Space add/remove"), "{all}"); assert!(all.contains("t test"), "{all}"); assert!(!all.contains("s add/remove"), "{all}"); assert!(!all.contains("c stream check"), "{all}"); @@ -7373,7 +7580,7 @@ fn openclaw_provider_detail_key_bar_uses_common_provider_actions() { all.push('\n'); } - assert!(all.contains("Space switch"), "{all}"); + assert!(all.contains("Space add/remove"), "{all}"); assert!(all.contains("t test"), "{all}"); assert!(all.contains("x set default"), "{all}"); assert!(!all.contains("s add/remove"), "{all}"); @@ -7393,7 +7600,7 @@ fn opencode_provider_detail_key_bar_uses_config_membership_actions() { let all = all_text(&render(&app, &data)); - assert!(all.contains("Space switch"), "{all}"); + assert!(all.contains("Space add/remove"), "{all}"); assert!(all.contains("t test"), "{all}"); assert!(!all.contains("s add/remove"), "{all}"); assert!(!all.contains("c stream check"), "{all}"); @@ -7421,6 +7628,29 @@ fn openclaw_provider_list_key_bar_shows_edit_for_tracked_provider() { assert!(all.contains("x set default"), "{all}"); } +#[test] +fn hermes_provider_list_key_bar_hides_edit_delete_for_read_only_provider() { + let _lock = lock_env(); + let _no_color = EnvGuard::remove("NO_COLOR"); + + let mut app = App::new(Some(AppType::Hermes)); + app.route = Route::Providers; + app.focus = Focus::Content; + let mut data = minimal_data(&app.app_type); + data.providers.rows[0].provider.settings_config = json!({ + "_cc_source": crate::hermes_config::PROVIDER_SOURCE_DICT, + "base_url": "https://example.com", + "models": [{"id": "main"}] + }); + + let all = all_text(&render(&app, &data)); + + let keys = line_with(&all, "Space add/remove"); + assert!(keys.contains("t test"), "{keys}"); + assert!(!keys.contains("e edit"), "{keys}"); + assert!(!keys.contains("d delete"), "{keys}"); +} + #[test] fn openclaw_provider_detail_key_bar_shows_edit_for_tracked_provider() { let _lock = lock_env(); @@ -7718,9 +7948,10 @@ fn openclaw_provider_list_key_bar_localizes_actions_in_chinese() { let all = all_text(&render(&app, &minimal_data(&app.app_type))); let compact = all.replace(' ', ""); - assert!(compact.contains("Space切换"), "{all}"); + assert!(compact.contains("Space添加/移除"), "{all}"); assert!(compact.contains("t测试"), "{all}"); assert!(compact.contains("x设为默认"), "{all}"); + assert!(!compact.contains("Space切换"), "{all}"); assert!(!compact.contains("s添加/移除"), "{all}"); assert!(!all.contains("add/remove"), "{all}"); assert!(!all.contains("set default"), "{all}"); @@ -7741,9 +7972,10 @@ fn openclaw_provider_detail_key_bar_localizes_actions_in_chinese() { let all = all_text(&render(&app, &minimal_data(&app.app_type))); let compact = all.replace(' ', ""); - assert!(compact.contains("Space切换"), "{all}"); + assert!(compact.contains("Space添加/移除"), "{all}"); assert!(compact.contains("t测试"), "{all}"); assert!(compact.contains("x设为默认"), "{all}"); + assert!(!compact.contains("Space切换"), "{all}"); assert!(!compact.contains("s添加/移除"), "{all}"); assert!(!all.contains("add/remove"), "{all}"); assert!(!all.contains("set default"), "{all}"); diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index 81d6d692..8d047058 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -34,6 +34,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Claude => InquireColor::LightCyan, AppType::Gemini => InquireColor::LightMagenta, AppType::OpenCode => InquireColor::LightGreen, + AppType::Hermes => InquireColor::LightBlue, AppType::OpenClaw => InquireColor::LightRed, } } @@ -85,6 +86,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Claude => Color::BrightCyan, AppType::Gemini => Color::BrightMagenta, AppType::OpenCode => Color::BrightGreen, + AppType::Hermes => Color::BrightBlue, AppType::OpenClaw => Color::BrightRed, } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 2254cb06..d38c5297 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -22,7 +22,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at FROM skills ORDER BY name ASC", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -43,8 +43,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }) .map_err(|e| AppError::Database(e.to_string()))?; @@ -63,7 +64,7 @@ impl Database { let mut stmt = conn .prepare( "SELECT id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at FROM skills WHERE id = ?1", ) .map_err(|e| AppError::Database(e.to_string()))?; @@ -83,8 +84,9 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: row.get(12)?, }, - installed_at: row.get(12)?, + installed_at: row.get(13)?, }) }); @@ -101,8 +103,8 @@ impl Database { conn.execute( "INSERT OR REPLACE INTO skills (id, name, description, directory, repo_owner, repo_name, repo_branch, - readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, installed_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes, installed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)", params![ skill.id, skill.name, @@ -116,6 +118,7 @@ impl Database { skill.apps.codex, skill.apps.gemini, skill.apps.opencode, + skill.apps.hermes, skill.installed_at, ], ) @@ -145,8 +148,8 @@ impl Database { let conn = lock_conn!(self.conn); let affected = conn .execute( - "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5", - params![apps.claude, apps.codex, apps.gemini, apps.opencode, id], + "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6", + params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id], ) .map_err(|e| AppError::Database(e.to_string()))?; Ok(affected > 0) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index c6867050..2f53392f 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -67,6 +67,7 @@ pub fn import_provider_from_deeplink( Some("codex") => Some("https://openai.com".to_string()), Some("gemini") => Some("https://ai.google.dev".to_string()), Some("opencode") => Some("https://opencode.ai".to_string()), + Some("hermes") => Some("https://hermes.sh".to_string()), _ => None, }; } @@ -139,6 +140,7 @@ fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), + AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), }; @@ -329,6 +331,31 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } +fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + let mut settings = serde_json::Map::new(); + settings.insert( + "name".to_string(), + json!(request.name.clone().unwrap_or_else(|| "custom".to_string())), + ); + + if !endpoint.is_empty() { + settings.insert("baseUrl".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + settings.insert("apiKey".to_string(), json!(api_key)); + } + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + settings.insert("model".to_string(), json!(model)); + } + + Value::Object(settings) +} + fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { if let Some(config) = &request.openclaw_config { let mut settings = match config { @@ -434,6 +461,7 @@ pub fn parse_and_merge_config( "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, "opencode" => merge_additive_config(&mut merged, &config_value)?, + "hermes" => merge_additive_config(&mut merged, &config_value)?, "openclaw" => merge_openclaw_config(&mut merged, &config_value)?, "" => return Ok(merged), other => return Err(AppError::InvalidInput(format!("Invalid app type: {other}"))), diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 00000000..4fb92305 --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,1249 @@ +//! Hermes Agent configuration read/write module (ported from upstream cc-switch). +//! +//! Handles read/write operations on `~/.hermes/config.yaml` (YAML format). +//! Hermes uses additive provider management: all provider configurations +//! coexist in the same config file. +//! +//! ## Example config layout +//! +//! ```yaml +//! model: +//! default: "anthropic/claude-opus-4-7" +//! provider: "openrouter" +//! base_url: "https://openrouter.ai/api/v1" +//! +//! agent: +//! max_turns: 50 +//! reasoning_effort: "high" +//! +//! custom_providers: +//! - name: openrouter +//! base_url: https://openrouter.ai/api/v1 +//! api_key: sk-or-... +//! model: anthropic/claude-opus-4-7 +//! models: +//! anthropic/claude-opus-4-7: +//! context_length: 200000 +//! +//! mcp_servers: +//! filesystem: +//! command: npx +//! args: ["-y", "@modelcontextprotocol/server-filesystem"] +//! ``` + +use crate::config::{atomic_write, get_app_config_dir, home_dir}; +use crate::error::AppError; +use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; +use chrono::Local; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +// ============================================================================ +// Path Functions +// ============================================================================ + +/// Get the Hermes config directory. +/// +/// Default: `~/.hermes/`. Can be overridden via `settings.hermes_config_dir`. +pub fn get_hermes_dir() -> PathBuf { + if let Some(override_dir) = get_hermes_override_dir() { + return override_dir; + } + + home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + +/// Get the Hermes config file path (`/config.yaml`). +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + +fn hermes_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// Hermes write outcome (kept for upstream API compatibility; current CLI +/// callers do not consume this). +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HermesWriteOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub backup_path: Option, +} + +/// Hermes top-level `model:` section. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct HermesModelConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Preserve unknown fields for forward compatibility. + #[serde(flatten)] + pub extra: HashMap, +} + +// ============================================================================ +// Core YAML / Text Read Functions +// ============================================================================ + +/// Read the raw Hermes config file (unparsed). Returns `None` if absent. +pub fn read_hermes_config_source() -> Result, AppError> { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(None); + } + + fs::read_to_string(&path) + .map(Some) + .map_err(|e| AppError::io(&path, e)) +} + +/// Write raw Hermes config (no backup created; intended for snapshot/restore +/// callers). +pub fn write_hermes_config_source(source: &str) -> Result<(), AppError> { + let path = get_hermes_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write(&path, source.as_bytes()) +} + +/// Read the Hermes config file as `serde_yaml::Value`. Returns an empty +/// `Mapping` if the file is missing or empty. +pub fn read_hermes_config() -> Result { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + if content.trim().is_empty() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + serde_yaml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}"))) +} + +/// Read the Hermes config file as `serde_json::Value` for service-layer +/// callers that expose full live config as JSON. +pub fn read_hermes_config_json() -> Result { + let yaml_value = read_hermes_config()?; + yaml_to_json(&yaml_value) +} + +// ============================================================================ +// YAML <-> JSON Conversion Helpers (public so e.g. `mcp::hermes_*` can reuse) +// ============================================================================ + +/// Convert `serde_yaml::Value` to `serde_json::Value`. +pub fn yaml_to_json(yaml: &serde_yaml::Value) -> Result { + let yaml_str = serde_yaml::to_string(yaml) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML value: {e}")))?; + serde_yaml::from_str::(&yaml_str) + .map_err(|e| AppError::Config(format!("Failed to convert YAML to JSON: {e}"))) +} + +/// Convert `serde_json::Value` to `serde_yaml::Value`. +pub fn json_to_yaml(json: &Value) -> Result { + let json_str = serde_json::to_string(json) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; + serde_yaml::from_str(&json_str) + .map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}"))) +} + +// ============================================================================ +// YAML Section-Level Replacement +// ============================================================================ + +/// Returns true if `line` is a YAML top-level key: column 0, not a comment, +/// not a sequence item, and contains a `:` followed by space/EOL. +fn is_top_level_key_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + let first_char = line.as_bytes()[0]; + if first_char == b' ' || first_char == b'\t' || first_char == b'#' || first_char == b'-' { + return false; + } + if let Some(colon_pos) = line.find(':') { + let after_colon = &line[colon_pos + 1..]; + after_colon.is_empty() || after_colon.starts_with(' ') || after_colon.starts_with('\t') + } else { + false + } +} + +/// Locate the byte range of a top-level YAML section. Returns +/// `(start_inclusive, end_exclusive)`, or `None` if the section is absent. +fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { + let target = format!("{}:", section_key); + let mut section_start = None; + let mut offset = 0; + + for line in raw.split('\n') { + if section_start.is_none() && is_top_level_key_line(line) && line.starts_with(&target) { + let after_target = &line[target.len()..]; + if after_target.is_empty() + || after_target.starts_with(' ') + || after_target.starts_with('\t') + || after_target.starts_with('\r') + { + section_start = Some(offset); + } + } else if section_start.is_some() && is_top_level_key_line(line) { + return Some((section_start.unwrap(), offset)); + } + offset += line.len() + 1; // +1 for \n + } + + section_start.map(|start| (start, raw.len())) +} + +/// Serialise `key: value` to a YAML fragment. +fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { + let mut section = serde_yaml::Mapping::new(); + section.insert(serde_yaml::Value::String(key.to_string()), value.clone()); + serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}"))) +} + +/// Replace the named section in `raw`. If the section is absent, append it +/// to the end of the file. +fn replace_yaml_section( + raw: &str, + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let serialized = serialize_yaml_section(section_key, value)?; + + if let Some((start, end)) = find_yaml_section_range(raw, section_key) { + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + result.push_str(&serialized); + let remainder = &raw[end..]; + if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') { + result.push('\n'); + } + result.push_str(remainder); + Ok(result) + } else { + let mut result = raw.to_string(); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&serialized); + if !result.ends_with('\n') { + result.push('\n'); + } + Ok(result) + } +} + +// ============================================================================ +// Backup & Cleanup +// ============================================================================ + +fn create_hermes_backup(source: &str) -> Result { + let backup_dir = get_app_config_dir().join("backups").join("hermes"); + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); + let mut filename = format!("{base_id}.yaml"); + let mut backup_path = backup_dir.join(&filename); + let mut counter = 1; + + while backup_path.exists() { + filename = format!("{base_id}_{counter}.yaml"); + backup_path = backup_dir.join(&filename); + counter += 1; + } + + atomic_write(&backup_path, source.as_bytes())?; + cleanup_hermes_backups(&backup_dir)?; + Ok(backup_path) +} + +fn cleanup_hermes_backups(dir: &Path) -> Result<(), AppError> { + let retain = effective_backup_retain_count(); + let mut entries = fs::read_dir(dir) + .map_err(|e| AppError::io(dir, e))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) + .collect::>(); + + if entries.len() <= retain { + return Ok(()); + } + + entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + let remove_count = entries.len().saturating_sub(retain); + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old Hermes config backup {}: {err}", + entry.path().display() + ); + } + } + + Ok(()) +} + +// ============================================================================ +// High-level Section Write +// ============================================================================ + +fn write_yaml_section_to_config( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + write_yaml_section_to_config_locked(section_key, value) +} + +fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let config_path = get_hermes_config_path(); + let raw = if config_path.exists() { + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let new_raw = replace_yaml_section(&raw, section_key, value)?; + + if new_raw == raw { + return Ok(HermesWriteOutcome::default()); + } + + let backup_path = if !raw.is_empty() { + Some(create_hermes_backup(&raw)?) + } else { + None + }; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&config_path, new_raw.as_bytes())?; + + log::debug!( + "Hermes config section '{}' written to {:?}", + section_key, + config_path + ); + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + }) +} + +// ============================================================================ +// Provider Helpers: models array <-> dict, key normalization, source marker +// ============================================================================ + +/// Convert the UI-friendly array form of `models` to Hermes' YAML dict shape. +/// +/// Entries with a missing/empty `id` are dropped. The `id` field is hoisted +/// to be the map key. Insertion order is preserved (requires the +/// `preserve_order` feature on `serde_json`). +fn models_array_to_dict(array: Vec) -> Value { + let mut map = Map::new(); + for item in array { + let Value::Object(mut obj) = item else { + continue; + }; + let Some(id) = obj + .remove("id") + .and_then(|v| v.as_str().map(|s| s.trim().to_string())) + .filter(|s| !s.is_empty()) + else { + continue; + }; + map.insert(id, Value::Object(obj)); + } + Value::Object(map) +} + +/// Inverse of [`models_array_to_dict`]: YAML dict -> ordered array, with +/// `id` re-injected as an object field. +fn models_dict_to_array(dict: Map) -> Value { + let mut out = Vec::with_capacity(dict.len()); + for (id, value) in dict { + let mut obj = match value { + Value::Object(obj) => obj, + Value::Null => Map::new(), + other => { + log::warn!("Unexpected Hermes model entry for '{id}': {other:?}, skipping"); + continue; + } + }; + obj.insert("id".to_string(), Value::String(id)); + out.push(Value::Object(obj)); + } + Value::Array(out) +} + +/// Source marker: entries read from Hermes v12+ `providers:` dict carry +/// this field so the UI can render them read-only. +pub const PROVIDER_SOURCE_FIELD: &str = "_cc_source"; +pub const PROVIDER_SOURCE_CUSTOM_LIST: &str = "custom_providers"; +pub const PROVIDER_SOURCE_DICT: &str = "providers_dict"; + +/// Rewrite historical camelCase keys to Hermes' snake_case schema. +fn sanitize_hermes_provider_keys(config: &mut Value) { + const KEY_ALIASES: &[(&str, &str)] = &[ + ("baseUrl", "base_url"), + ("apiKey", "api_key"), + ("apiMode", "api_mode"), + ("maxTokens", "max_tokens"), + ("contextLength", "context_length"), + ]; + // Legacy fields that are neither valid Hermes keys nor mappable to + // `api_mode`; also strip UI-only source markers before writing YAML. + const LEGACY_FIELDS_TO_DROP: &[&str] = &["api", PROVIDER_SOURCE_FIELD, "provider_key"]; + + let Some(obj) = config.as_object_mut() else { + return; + }; + + for (from, to) in KEY_ALIASES { + if let Some(val) = obj.remove(*from) { + obj.entry((*to).to_string()).or_insert(val); + } + } + + for field in LEGACY_FIELDS_TO_DROP { + obj.remove(*field); + } +} + +/// Pre-write: if `models` is a JSON array, convert it in-place to a dict. +fn normalize_provider_models_for_write(config: &mut Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_array() { + let taken = std::mem::take(models_val); + if let Value::Array(arr) = taken { + *models_val = models_array_to_dict(arr); + } + } +} + +/// Post-read: if `models` is a JSON dict, convert it in-place to an ordered +/// array. +fn denormalize_provider_models_for_read(config: &mut Value) { + let Some(obj) = config.as_object_mut() else { + return; + }; + let Some(models_val) = obj.get_mut("models") else { + return; + }; + if models_val.is_object() { + let taken = std::mem::take(models_val); + if let Value::Object(map) = taken { + *models_val = models_dict_to_array(map); + } + } +} + +/// Normalise a single `providers:` dict entry into the same shape as items +/// in the `custom_providers:` list. +fn normalize_providers_dict_entry( + key: &str, + entry: &serde_yaml::Value, +) -> Result, AppError> { + if !entry.is_mapping() { + return Ok(None); + } + let mut json_val = yaml_to_json(entry)?; + let Some(obj) = json_val.as_object_mut() else { + return Ok(None); + }; + let resolved_name = obj + .get("name") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| key.trim().to_string()); + if resolved_name.is_empty() { + return Ok(None); + } + obj.insert("name".to_string(), json!(resolved_name)); + obj.insert("provider_key".to_string(), json!(key)); + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + json!(PROVIDER_SOURCE_DICT), + ); + Ok(Some(json_val)) +} + +/// Collect provider entries from the `providers:` dict. +fn read_providers_dict_entries(config: &serde_yaml::Value) -> Vec<(String, Value)> { + let Some(mapping) = config.get("providers").and_then(|v| v.as_mapping()) else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(mapping.len()); + for (k, v) in mapping { + let Some(key_str) = k.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + match normalize_providers_dict_entry(key_str, v) { + Ok(Some(entry)) => { + let name = entry + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or(key_str) + .to_string(); + out.push((name, entry)); + } + Ok(None) => { + log::debug!("Skipping Hermes providers['{key_str}']: not a mapping"); + } + Err(e) => { + log::warn!("Failed to normalize Hermes providers['{key_str}']: {e}"); + } + } + } + out +} + +/// Returns true when `name` only appears in the `providers:` dict (i.e. it +/// is read-only from CC Switch's perspective). +fn is_dict_only_provider(config: &serde_yaml::Value, name: &str) -> bool { + let list_has = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .any(|item| item.get("name").and_then(|n| n.as_str()) == Some(name)) + }) + .unwrap_or(false); + if list_has { + return false; + } + config + .get("providers") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter().any(|(k, v)| { + let key_matches = k.as_str() == Some(name); + let name_matches = v + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s == name) + .unwrap_or(false); + (key_matches || name_matches) && v.is_mapping() + }) + }) + .unwrap_or(false) +} + +/// Reject writes that target providers living in the `providers:` overlay +/// dict. +fn ensure_provider_writable( + config: &serde_yaml::Value, + name: &str, + verb: &str, +) -> Result<(), AppError> { + if is_dict_only_provider(config, name) { + return Err(AppError::Config(format!( + "Provider '{name}' is managed by the Hermes 'providers:' dict \ + — please {verb} it via the Hermes web UI" + ))); + } + Ok(()) +} + +// ============================================================================ +// Provider Public API +// ============================================================================ + +/// Get all providers indexed by name. +/// +/// Merges two sources: +/// - `custom_providers:` list (writable from CC Switch) +/// - `providers:` dict (v12+; read-only, tagged with +/// `_cc_source = "providers_dict"`) +/// +/// On name collision, the list wins. The `models` field is denormalised +/// from the YAML dict back to an ordered array. +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config()?; + let mut map = IndexMap::new(); + + if let Some(seq) = config.get("custom_providers").and_then(|v| v.as_sequence()) { + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + match yaml_to_json(item) { + Ok(mut json_val) => { + sanitize_hermes_provider_keys(&mut json_val); + denormalize_provider_models_for_read(&mut json_val); + if let Some(obj) = json_val.as_object_mut() { + obj.insert( + PROVIDER_SOURCE_FIELD.to_string(), + json!(PROVIDER_SOURCE_CUSTOM_LIST), + ); + } + map.insert(name.to_string(), json_val); + } + Err(e) => { + log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); + } + } + } + } + } + + for (name, mut entry) in read_providers_dict_entries(&config) { + if map.contains_key(&name) { + continue; // List wins on name collision. + } + denormalize_provider_models_for_read(&mut entry); + map.insert(name, entry); + } + + Ok(map) +} + +/// Get a single provider by name. +pub fn get_provider(name: &str) -> Result, AppError> { + Ok(get_providers()?.get(name).cloned()) +} + +/// Insert or update a provider in the `custom_providers:` list. +/// +/// - Matches existing entries by `name`. +/// - Pre-write: camelCase keys are normalised to snake_case, and `models` +/// arrays are converted to dicts. +/// - Mirrors the first `models` key onto the top-level `model:` field +/// (this is what Hermes actually reads on activation). +/// - For existing entries, performs a forward-compat merge: fields present +/// on disk but not submitted by the UI are preserved. +/// - Holds the write lock end-to-end to avoid TOCTOU races. +pub fn set_provider(name: &str, provider_config: Value) -> Result { + let _guard = hermes_write_lock().lock()?; + + let config = read_hermes_config()?; + ensure_provider_writable(&config, name, "edit")?; + + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let mut normalized = provider_config; + sanitize_hermes_provider_keys(&mut normalized); + normalize_provider_models_for_write(&mut normalized); + + let first_model_id = normalized + .get("models") + .and_then(|v| v.as_object()) + .and_then(|obj| obj.keys().next()) + .cloned(); + + let mut yaml_val: serde_yaml::Value = json_to_yaml(&normalized)?; + if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { + m.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + if let Some(model_id) = first_model_id { + m.insert( + serde_yaml::Value::String("model".to_string()), + serde_yaml::Value::String(model_id), + ); + } else { + m.remove(serde_yaml::Value::String("model".to_string())); + } + } + + if let Some(existing) = providers + .iter_mut() + .find(|p| p.get("name").and_then(|n| n.as_str()) == Some(name)) + { + if let (Some(existing_map), serde_yaml::Value::Mapping(new_map)) = + (existing.as_mapping(), &mut yaml_val) + { + for (k, v) in existing_map { + new_map.entry(k.clone()).or_insert_with(|| v.clone()); + } + } + *existing = yaml_val; + } else { + providers.push(yaml_val); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +/// Remove a provider from the `custom_providers:` list. +pub fn remove_provider(name: &str) -> Result { + let _guard = hermes_write_lock().lock()?; + let config = read_hermes_config()?; + + ensure_provider_writable(&config, name, "remove")?; + + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let original_len = providers.len(); + providers.retain(|p| p.get("name").and_then(|n| n.as_str()) != Some(name)); + if providers.len() == original_len { + return Ok(HermesWriteOutcome::default()); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +// ============================================================================ +// Current Provider Helpers +// ============================================================================ + +fn primary_model_id_from_value(value: &Value) -> Option { + value + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn provider_matches_model(provider: &Value, model_id: &str) -> bool { + let model_id = model_id.trim(); + if model_id.is_empty() { + return false; + } + + provider + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + || provider + .get("models") + .and_then(Value::as_object) + .is_some_and(|models| models.contains_key(model_id)) + || provider + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + }) + }) +} + +/// Get the currently active provider id (driven by top-level +/// `model.provider`). +pub fn get_current_provider_id() -> Result, AppError> { + let config = read_hermes_config_json()?; + let Some(model) = config.get("model").and_then(Value::as_object) else { + return Ok(None); + }; + + let provider_ref = model + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + + if !provider_ref.is_empty() { + if get_providers()?.contains_key(provider_ref) { + return Ok(Some(provider_ref.to_string())); + } + } + + if provider_ref == "custom" { + let default_model = model + .get("default") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(default_model) = default_model { + for (id, provider) in get_providers()? { + if provider_matches_model(&provider, default_model) { + return Ok(Some(id)); + } + } + } + } + + Ok(None) +} + +/// Switch to a given provider by writing the top-level `model:` section. +/// +/// `model.provider` is always updated; `model.default` is only overwritten +/// when the new provider declares at least one model — otherwise the old +/// value is preserved to avoid leaving Hermes without an available model. +pub fn set_current_provider(id: &str, provider: &Value) -> Result { + apply_switch_defaults(id, provider) +} + +// ============================================================================ +// Model Section +// ============================================================================ + +/// Read the top-level `model:` section. +pub fn get_model_config() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(model_value) = config.get("model") else { + return Ok(None); + }; + let json_val = yaml_to_json(model_value)?; + let model = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes model config: {e}")))?; + Ok(Some(model)) +} + +/// Write the top-level `model:` section. +pub fn set_model_config(model: &HermesModelConfig) -> Result { + let json_val = + serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("model", &yaml_val) +} + +/// Refresh the top-level `model:` defaults when switching providers. +pub fn apply_switch_defaults( + provider_id: &str, + settings_config: &Value, +) -> Result { + let first_model_id = primary_model_id_from_value(settings_config); + + let current = get_model_config()?.unwrap_or_default(); + let merged = HermesModelConfig { + default: first_model_id.or(current.default.clone()), + provider: Some(provider_id.to_string()), + ..current + }; + set_model_config(&merged) +} + +// ============================================================================ +// MCP Section Access (consumed by `mcp::hermes_*` helpers) +// ============================================================================ + +/// Get the `mcp_servers:` section. +pub fn get_mcp_servers_yaml() -> Result { + let config = read_hermes_config()?; + Ok(config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default()) +} + +/// Read-modify-write the `mcp_servers:` section under the write lock. +pub fn update_mcp_servers_yaml(updater: F) -> Result<(), AppError> +where + F: FnOnce(&mut serde_yaml::Mapping) -> Result<(), AppError>, +{ + let _guard = hermes_write_lock() + .lock() + .map_err(|e| AppError::Config(format!("Failed to acquire Hermes write lock: {e}")))?; + let config = read_hermes_config()?; + let mut servers = config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default(); + updater(&mut servers)?; + let value = serde_yaml::Value::Mapping(servers); + write_yaml_section_to_config_locked("mcp_servers", &value)?; + Ok(()) +} + +// ============================================================================ +// Memory Files (~/.hermes/memories/{MEMORY,USER}.md) +// ============================================================================ + +/// The two Hermes memory blob kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum MemoryKind { + Memory, + User, +} + +impl MemoryKind { + pub fn filename(self) -> &'static str { + match self { + Self::Memory => "MEMORY.md", + Self::User => "USER.md", + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Memory => "memory", + Self::User => "user", + } + } +} + +fn memories_dir() -> PathBuf { + get_hermes_dir().join("memories") +} + +/// Read a Hermes memory file. Returns an empty string when the file does +/// not exist. +pub fn read_memory(kind: MemoryKind) -> Result { + let path = memories_dir().join(kind.filename()); + match fs::read_to_string(&path) { + Ok(content) => Ok(content), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(AppError::io(&path, e)), + } +} + +/// Atomically write a Hermes memory file. +pub fn write_memory(kind: MemoryKind, content: &str) -> Result<(), AppError> { + let path = memories_dir().join(kind.filename()); + atomic_write(&path, content.as_bytes()) +} + +/// Per-blob character budget plus enable flags. Missing fields fall back +/// to defaults. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HermesMemoryLimits { + pub memory: usize, + pub user: usize, + pub memory_enabled: bool, + pub user_enabled: bool, +} + +impl Default for HermesMemoryLimits { + fn default() -> Self { + Self { + memory: 2200, + user: 1375, + memory_enabled: true, + user_enabled: true, + } + } +} + +/// Toggle a memory blob on/off while preserving the rest of the `memory:` +/// section. +pub fn set_memory_enabled(kind: MemoryKind, enabled: bool) -> Result { + let _guard = hermes_write_lock().lock()?; + let config = read_hermes_config()?; + + let mut memory = match config.get("memory") { + Some(serde_yaml::Value::Mapping(m)) => m.clone(), + _ => serde_yaml::Mapping::new(), + }; + + let key = match kind { + MemoryKind::Memory => "memory_enabled", + MemoryKind::User => "user_profile_enabled", + }; + memory.insert( + serde_yaml::Value::String(key.to_string()), + serde_yaml::Value::Bool(enabled), + ); + + write_yaml_section_to_config_locked("memory", &serde_yaml::Value::Mapping(memory)) +} + +/// Read memory budgets + enable flags. Falls back to defaults for any +/// field that fails to parse. +pub fn read_memory_limits() -> Result { + let mut out = HermesMemoryLimits::default(); + let config = read_hermes_config()?; + let Some(memory) = config.get("memory") else { + return Ok(out); + }; + + if let Some(v) = memory.get("memory_char_limit").and_then(|v| v.as_u64()) { + out.memory = v as usize; + } + if let Some(v) = memory.get("user_char_limit").and_then(|v| v.as_u64()) { + out.user = v as usize; + } + if let Some(v) = memory.get("memory_enabled").and_then(|v| v.as_bool()) { + out.memory_enabled = v; + } + if let Some(v) = memory.get("user_profile_enabled").and_then(|v| v.as_bool()) { + out.user_enabled = v; + } + + Ok(out) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use serial_test::serial; + use std::sync::{Mutex, OnceLock}; + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + fn with_test_home(test_fn: impl FnOnce() -> T) -> T { + let _guard = test_guard(); + let tmp = tempfile::tempdir().unwrap(); + crate::test_support::set_test_home_override(Some(tmp.path())); + let result = test_fn(); + crate::test_support::set_test_home_override(None); + result + } + + #[test] + fn sanitize_rewrites_camel_case_aliases() { + let mut v = json!({ + "baseUrl": "https://x", + "apiKey": "k", + "maxTokens": 4096, + "contextLength": 200000, + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(obj.contains_key("base_url")); + assert!(obj.contains_key("api_key")); + assert!(obj.contains_key("max_tokens")); + assert!(obj.contains_key("context_length")); + assert!(!obj.contains_key("baseUrl")); + } + + #[test] + fn sanitize_drops_legacy_fields() { + let mut v = json!({ + "api": "openai-completions", + "_cc_source": "custom_providers", + "provider_key": "x", + "base_url": "https://x", + }); + sanitize_hermes_provider_keys(&mut v); + let obj = v.as_object().unwrap(); + assert!(!obj.contains_key("api")); + assert!(!obj.contains_key("_cc_source")); + assert!(!obj.contains_key("provider_key")); + assert!(obj.contains_key("base_url")); + } + + #[test] + fn models_array_to_dict_roundtrip() { + let arr = vec![ + json!({"id": "foo", "context_length": 200000}), + json!({"id": "bar"}), + json!({"id": " "}), + json!({"context_length": 1}), + ]; + let dict = models_array_to_dict(arr); + let map = dict.as_object().unwrap(); + assert_eq!(map.len(), 2); + assert!(map.contains_key("foo")); + assert!(map.contains_key("bar")); + + let back = models_dict_to_array(map.clone()); + let arr = back.as_array().unwrap(); + assert_eq!(arr.len(), 2); + let ids: Vec<&str> = arr.iter().filter_map(|v| v["id"].as_str()).collect(); + assert!(ids.contains(&"foo")); + assert!(ids.contains(&"bar")); + } + + #[test] + #[serial(home_settings)] + fn set_and_get_provider_roundtrip() { + with_test_home(|| { + let provider = json!({ + "base_url": "https://example.com/v1", + "api_key": "sk-test", + "models": [ + {"id": "gpt-4o", "context_length": 128000}, + {"id": "gpt-3.5"} + ] + }); + set_provider("acme", provider).unwrap(); + + let got = get_provider("acme").unwrap().expect("provider exists"); + assert_eq!(got["base_url"], "https://example.com/v1"); + // models should be in array form + let arr = got["models"].as_array().unwrap(); + assert_eq!(arr.len(), 2); + // first model id reflected to top-level + // (read goes through get_providers which strips dict form back to array) + let yaml = read_hermes_config().unwrap(); + let seq = yaml["custom_providers"].as_sequence().unwrap(); + let entry = seq + .iter() + .find(|p| p["name"].as_str() == Some("acme")) + .unwrap(); + assert_eq!(entry["model"].as_str(), Some("gpt-4o")); + // models in YAML must be a mapping + assert!(entry["models"].is_mapping()); + }); + } + + #[test] + #[serial(home_settings)] + fn remove_provider_works() { + with_test_home(|| { + set_provider("foo", json!({"base_url": "u1"})).unwrap(); + set_provider("bar", json!({"base_url": "u2"})).unwrap(); + remove_provider("foo").unwrap(); + let providers = get_providers().unwrap(); + assert!(!providers.contains_key("foo")); + assert!(providers.contains_key("bar")); + }); + } + + #[test] + #[serial(home_settings)] + fn dict_only_provider_is_read_only() { + with_test_home(|| { + let raw = "providers:\n remote:\n name: remote\n base_url: u\n"; + write_hermes_config_source(raw).unwrap(); + let providers = get_providers().unwrap(); + let entry = providers.get("remote").unwrap(); + assert_eq!(entry["_cc_source"], "providers_dict"); + // Edits / removes are rejected + let err = set_provider("remote", json!({"base_url": "x"})).unwrap_err(); + assert!(err.to_string().contains("providers")); + let err = remove_provider("remote").unwrap_err(); + assert!(err.to_string().contains("providers")); + }); + } + + #[test] + #[serial(home_settings)] + fn section_writes_preserve_other_sections() { + with_test_home(|| { + let raw = "# leading comment\n\ +agent:\n max_turns: 5\n\ +custom_providers: []\n"; + write_hermes_config_source(raw).unwrap(); + set_provider("foo", json!({"base_url": "u"})).unwrap(); + let text = read_hermes_config_source().unwrap().unwrap(); + assert!(text.contains("# leading comment")); + assert!(text.contains("agent:")); + assert!(text.contains("max_turns: 5")); + assert!(text.contains("foo")); + }); + } + + #[test] + #[serial(home_settings)] + fn memory_round_trip() { + with_test_home(|| { + write_memory(MemoryKind::Memory, "hello").unwrap(); + assert_eq!(read_memory(MemoryKind::Memory).unwrap(), "hello"); + assert_eq!(read_memory(MemoryKind::User).unwrap(), ""); + }); + } + + #[test] + #[serial(home_settings)] + fn memory_limits_defaults_when_absent() { + with_test_home(|| { + let limits = read_memory_limits().unwrap(); + assert_eq!(limits.memory, 2200); + assert_eq!(limits.user, 1375); + assert!(limits.memory_enabled); + assert!(limits.user_enabled); + }); + } + + #[test] + #[serial(home_settings)] + fn memory_set_enabled_preserves_other_fields() { + with_test_home(|| { + let raw = "memory:\n memory_char_limit: 4000\n user_char_limit: 2000\n"; + write_hermes_config_source(raw).unwrap(); + set_memory_enabled(MemoryKind::Memory, false).unwrap(); + let limits = read_memory_limits().unwrap(); + assert_eq!(limits.memory, 4000); + assert!(!limits.memory_enabled); + assert!(limits.user_enabled); + }); + } + + #[test] + #[serial(home_settings)] + fn set_current_provider_writes_model_section() { + with_test_home(|| { + set_provider( + "acme", + json!({ + "base_url": "https://x", + "models": [{"id": "model-a"}] + }), + ) + .unwrap(); + set_current_provider( + "acme", + &json!({ + "models": [{"id": "model-a"}] + }), + ) + .unwrap(); + let id = get_current_provider_id().unwrap().unwrap(); + assert_eq!(id, "acme"); + + let model = get_model_config().unwrap().unwrap(); + assert_eq!(model.provider.as_deref(), Some("acme")); + }); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6ea71769..714a18f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -12,6 +12,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +pub mod hermes_config; mod import_export; mod init_status; mod mcp; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 636923cc..32b82720 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -59,6 +59,7 @@ fn run(cli: Cli) -> Result<(), AppError> { Some(Commands::Failover(cmd)) => { cc_switch_lib::cli::commands::failover::execute(cmd, cli.app) } + Some(Commands::Hermes(cmd)) => cc_switch_lib::cli::commands::hermes::execute(cmd), #[cfg(unix)] Some(Commands::Start(cmd)) => cc_switch_lib::cli::commands::start::execute(cmd), #[cfg(unix)] diff --git a/src-tauri/src/mcp.rs b/src-tauri/src/mcp.rs index 856d72e3..331e1650 100644 --- a/src-tauri/src/mcp.rs +++ b/src-tauri/src/mcp.rs @@ -1388,3 +1388,430 @@ pub fn remove_server_from_opencode(id: &str) -> Result<(), AppError> { crate::opencode_config::remove_mcp_server(id) } + +// ============================================================================ +// Hermes MCP sync / remove / import +// ============================================================================ +// +// Behavioural notes (aligned with upstream `mcp/hermes.rs`): +// - Hermes has NO explicit `type` field; it infers `stdio` from `command` +// and `http` from `url`. +// - Hermes carries extra per-server fields: `enabled` / `timeout` / +// `connect_timeout` / `tools` / `sampling` / `roots` / `auth`. These are +// preserved on merge-on-write and stripped on import. + +/// Hermes-private fields preserved on write and stripped on import. +const HERMES_EXTRA_FIELDS: &[&str] = &[ + "enabled", + "timeout", + "connect_timeout", + "tools", + "sampling", + "roots", + "auth", +]; + +fn should_sync_hermes_mcp() -> bool { + crate::hermes_config::get_hermes_dir().exists() +} + +/// Convert CC Switch's unified MCP format to the Hermes YAML shape. +fn convert_to_hermes_mcp_spec(spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("MCP spec must be a JSON object".into()))?; + + let typ = obj.get("type").and_then(|v| v.as_str()).unwrap_or("stdio"); + let mut result = serde_json::Map::new(); + + match typ { + "stdio" => { + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } + "sse" | "http" => { + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) + { + result.insert("headers".into(), headers.clone()); + } + } + } + other => { + return Err(AppError::McpValidation(format!( + "Unknown MCP type: {other}" + ))); + } + } + + // Hermes expects an explicit `enabled` flag; default to true on write. + result.insert("enabled".into(), json!(true)); + + Ok(Value::Object(result)) +} + +/// Convert Hermes YAML shape back to CC Switch's unified format, stripping +/// Hermes-private fields on the import path. +fn convert_from_hermes_mcp_spec(id: &str, spec: &Value) -> Result { + let obj = spec + .as_object() + .ok_or_else(|| AppError::McpValidation("Hermes MCP spec must be a JSON object".into()))?; + + let mut result = serde_json::Map::new(); + + if obj.contains_key("command") { + result.insert("type".into(), json!("stdio")); + + if let Some(command) = obj.get("command") { + result.insert("command".into(), command.clone()); + } + if let Some(args) = obj.get("args") { + if args.is_array() && !args.as_array().map(|a| a.is_empty()).unwrap_or(true) { + result.insert("args".into(), args.clone()); + } + } + if let Some(env) = obj.get("env") { + if env.is_object() && !env.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("env".into(), env.clone()); + } + } + } else if obj.contains_key("url") { + result.insert("type".into(), json!("sse")); + + if let Some(url) = obj.get("url") { + result.insert("url".into(), url.clone()); + } + if let Some(headers) = obj.get("headers") { + if headers.is_object() && !headers.as_object().map(|o| o.is_empty()).unwrap_or(true) { + result.insert("headers".into(), headers.clone()); + } + } + } else { + return Err(AppError::McpValidation(format!( + "Hermes MCP server '{id}' has neither a 'command' nor 'url' field" + ))); + } + + Ok(Value::Object(result)) +} + +/// Merge: core fields come from `new_spec`, Hermes-specific fields are +/// preserved from `existing`. +fn merge_hermes_spec(existing: &Value, new_spec: &Value) -> Value { + let mut result = serde_json::Map::new(); + + if let Some(existing_obj) = existing.as_object() { + for &field in HERMES_EXTRA_FIELDS { + if let Some(val) = existing_obj.get(field) { + result.insert(field.to_string(), val.clone()); + } + } + } + + if let Some(new_obj) = new_spec.as_object() { + for (key, val) in new_obj { + if HERMES_EXTRA_FIELDS.contains(&key.as_str()) && result.contains_key(key) { + continue; // Existing Hermes-private fields win. + } + result.insert(key.clone(), val.clone()); + } + } + + Value::Object(result) +} + +/// Sync a single MCP server to the Hermes live config using +/// merge-on-write semantics (preserves Hermes-private fields). +pub fn sync_single_server_to_hermes( + _config: &MultiAppConfig, + id: &str, + server_spec: &Value, +) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&AppType::Hermes) { + return Ok(()); + } + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let hermes_spec = convert_to_hermes_mcp_spec(server_spec)?; + let id_owned = id.to_string(); + + crate::hermes_config::update_mcp_servers_yaml(|servers| { + let id_yaml = serde_yaml::Value::String(id_owned.clone()); + + let merged_json = if let Some(existing_yaml) = servers.get(&id_yaml) { + let existing_json = crate::hermes_config::yaml_to_json(existing_yaml)?; + merge_hermes_spec(&existing_json, &hermes_spec) + } else { + hermes_spec.clone() + }; + + let merged_yaml_value = crate::hermes_config::json_to_yaml(&merged_json)?; + servers.insert(id_yaml, merged_yaml_value); + Ok(()) + }) +} + +/// Remove a single MCP server from the Hermes live config. +pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> { + if !crate::sync_policy::should_sync_live(&AppType::Hermes) { + return Ok(()); + } + if !should_sync_hermes_mcp() { + return Ok(()); + } + + let id_owned = id.to_string(); + crate::hermes_config::update_mcp_servers_yaml(|servers| { + servers.remove(serde_yaml::Value::String(id_owned.clone())); + Ok(()) + }) +} + +/// Import MCP servers from the Hermes `mcp_servers:` section into the +/// unified store. +pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result { + use crate::app_config::{McpApps, McpServer}; + + let yaml_map = crate::hermes_config::get_mcp_servers_yaml()?; + if yaml_map.is_empty() { + return Ok(0); + } + + if config.mcp.servers.is_none() { + config.mcp.servers = Some(HashMap::new()); + } + let servers = config.mcp.servers.as_mut().unwrap(); + + let mut changed = 0usize; + let mut errors = Vec::new(); + + for (key, spec_yaml) in &yaml_map { + let id = match key.as_str() { + Some(s) => s.to_string(), + None => { + log::warn!("Skipping Hermes MCP server with non-string key"); + continue; + } + }; + + let spec_json = match crate::hermes_config::yaml_to_json(spec_yaml) { + Ok(j) => j, + Err(e) => { + log::warn!("Skipping Hermes MCP '{id}': YAML->JSON conversion failed: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + let unified_spec = match convert_from_hermes_mcp_spec(&id, &spec_json) { + Ok(s) => s, + Err(e) => { + log::warn!("Skipping invalid Hermes MCP '{id}': {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + }; + + if let Err(e) = validate_server_spec(&unified_spec) { + log::warn!("Skipping MCP '{id}' that remained invalid after conversion: {e}"); + errors.push(format!("{id}: {e}")); + continue; + } + + if let Some(existing) = servers.get_mut(&id) { + if !existing.apps.hermes { + existing.apps.hermes = true; + changed += 1; + log::info!("MCP server '{id}' enabled for Hermes"); + } + } else { + servers.insert( + id.clone(), + McpServer { + id: id.clone(), + name: id.clone(), + server: unified_spec, + apps: McpApps { + claude: false, + codex: false, + gemini: false, + opencode: false, + hermes: true, + }, + description: None, + homepage: None, + docs: None, + tags: Vec::new(), + }, + ); + changed += 1; + log::info!("Imported new MCP server '{id}' from Hermes"); + } + } + + if !errors.is_empty() { + log::warn!( + "Hermes MCP import finished with {} failure(s): {:?}", + errors.len(), + errors + ); + } + + Ok(changed) +} + +#[cfg(test)] +mod hermes_mcp_tests { + use super::*; + + #[test] + fn convert_stdio_to_hermes() { + let spec = json!({ + "type": "stdio", + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"], + "env": { "HOME": "/Users/test" } + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["command"], "npx"); + assert_eq!(result["args"][0], "-y"); + assert_eq!(result["env"]["HOME"], "/Users/test"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn convert_sse_to_hermes() { + let spec = json!({ + "type": "sse", + "url": "https://example.com/mcp", + "headers": { "Authorization": "Bearer xxx" } + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert!(result.get("type").is_none()); + assert_eq!(result["url"], "https://example.com/mcp"); + assert_eq!(result["headers"]["Authorization"], "Bearer xxx"); + assert_eq!(result["enabled"], true); + } + + #[test] + fn convert_stdio_empty_collections_are_omitted() { + let spec = json!({ + "type": "stdio", + "command": "node", + "args": [], + "env": {} + }); + let result = convert_to_hermes_mcp_spec(&spec).unwrap(); + assert_eq!(result["command"], "node"); + assert!(result.get("args").is_none()); + assert!(result.get("env").is_none()); + } + + #[test] + fn convert_from_hermes_stdio_strips_extras() { + let spec = json!({ + "command": "npx", + "args": ["-y", "x"], + "env": { "HOME": "/Users/test" }, + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + let result = convert_from_hermes_mcp_spec("fs", &spec).unwrap(); + assert_eq!(result["type"], "stdio"); + assert_eq!(result["command"], "npx"); + assert!(result.get("enabled").is_none()); + assert!(result.get("timeout").is_none()); + assert!(result.get("connect_timeout").is_none()); + assert!(result.get("tools").is_none()); + assert!(result.get("sampling").is_none()); + } + + #[test] + fn convert_from_hermes_http_strips_extras_and_auth() { + let spec = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true, + "timeout": 60 + }); + let result = convert_from_hermes_mcp_spec("remote", &spec).unwrap(); + assert_eq!(result["type"], "sse"); + assert_eq!(result["url"], "https://mcp.example.com"); + assert!( + result.get("auth").is_none(), + "auth must be stripped on import" + ); + assert!(result.get("enabled").is_none()); + } + + #[test] + fn convert_from_hermes_missing_endpoint_errors() { + let spec = json!({ "enabled": true, "timeout": 30 }); + assert!(convert_from_hermes_mcp_spec("bad", &spec).is_err()); + } + + #[test] + fn merge_preserves_hermes_extra_fields() { + let existing = json!({ + "command": "old-cmd", + "args": ["old-arg"], + "enabled": true, + "timeout": 30, + "connect_timeout": 10, + "tools": { "include": ["read_file"] }, + "sampling": { "enabled": true } + }); + let new_spec = json!({ + "command": "new-cmd", + "args": ["new-arg"], + "env": { "KEY": "value" }, + "enabled": true + }); + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["command"], "new-cmd"); + assert_eq!(merged["args"][0], "new-arg"); + assert_eq!(merged["env"]["KEY"], "value"); + assert_eq!(merged["timeout"], 30); + assert_eq!(merged["connect_timeout"], 10); + assert_eq!(merged["tools"]["include"][0], "read_file"); + assert_eq!(merged["sampling"]["enabled"], true); + } + + #[test] + fn merge_preserves_auth_field_on_roundtrip() { + let existing = json!({ + "url": "https://mcp.example.com", + "auth": "oauth", + "enabled": true + }); + let new_spec = json!({ + "url": "https://mcp.example.com/updated", + "headers": { "X-Trace": "abc" }, + "enabled": true + }); + let merged = merge_hermes_spec(&existing, &new_spec); + assert_eq!(merged["url"], "https://mcp.example.com/updated"); + assert_eq!(merged["headers"]["X-Trace"], "abc"); + assert_eq!(merged["auth"], "oauth"); + } +} diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 018dee81..20552b79 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -6,7 +6,7 @@ use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; use crate::opencode_config::get_opencode_dir; -use crate::settings::get_openclaw_override_dir; +use crate::settings::{get_hermes_override_dir, get_openclaw_override_dir}; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { @@ -15,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), + AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), }; @@ -23,6 +24,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode => "AGENTS.md", + AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", }; @@ -35,6 +37,12 @@ fn default_openclaw_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".openclaw")) } +fn default_hermes_dir() -> PathBuf { + dirs::home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + fn get_base_dir_with_fallback( primary_path: PathBuf, fallback_dir: &str, diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index a24348c6..fb03b307 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -115,7 +115,7 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::OpenClaw => ProviderType::Codex, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => ProviderType::Codex, } } @@ -165,6 +165,7 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::OpenCode => Box::new(CodexAdapter::new()), + AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index 50531e19..18d07c26 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -252,6 +252,7 @@ impl ConfigService { Self::sync_current_provider_for_app(config, &AppType::Codex)?; Self::sync_current_provider_for_app(config, &AppType::Gemini)?; Self::sync_current_provider_for_app(config, &AppType::OpenCode)?; + Self::sync_current_provider_for_app(config, &AppType::Hermes)?; Self::sync_current_provider_for_app(config, &AppType::OpenClaw)?; Ok(()) } @@ -288,6 +289,7 @@ impl ConfigService { AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, AppType::OpenCode => {} + AppType::Hermes => {} AppType::OpenClaw => {} } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ae4d465f..90505316 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -163,6 +163,9 @@ impl McpService { AppType::OpenCode => { mcp::sync_single_server_to_opencode(cfg, &server.id, &server.server)?; } + AppType::Hermes => { + mcp::sync_single_server_to_hermes(cfg, &server.id, &server.server)?; + } AppType::OpenClaw => {} } Ok(()) @@ -187,6 +190,7 @@ impl McpService { AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, + AppType::Hermes => mcp::remove_server_from_hermes(id)?, AppType::OpenClaw => {} } Ok(()) @@ -296,4 +300,13 @@ impl McpService { state.save()?; Ok(count) } + + /// 从 Hermes 导入 MCP + pub fn import_from_hermes(state: &AppState) -> Result { + let mut cfg = state.config.write()?; + let count = mcp::import_from_hermes(&mut cfg)?; + drop(cfg); + state.save()?; + Ok(count) + } } diff --git a/src-tauri/src/services/provider/codex.rs b/src-tauri/src/services/provider/codex.rs index 0ce59b13..a3104edd 100644 --- a/src-tauri/src/services/provider/codex.rs +++ b/src-tauri/src/services/provider/codex.rs @@ -90,6 +90,10 @@ impl ProviderService { root.remove("model_provider"); // Legacy/alt formats might use a top-level base_url. root.remove("base_url"); + // Profiles can carry provider-specific model_provider overrides. Keep them + // in the provider snapshot so storage normalization can restore ids. + root.remove("profile"); + root.remove("profiles"); // Remove entire model_providers table (provider-specific configuration) root.remove("model_providers"); diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index e8ee01b3..42b3fd08 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -284,7 +284,7 @@ fn parse_json_object_snippet( format!("Gemini 通用配置片段不是有效的 JSON:{e}"), format!("Gemini common config snippet is not valid JSON: {e}"), ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), format!("OpenCode common config snippet is not valid JSON: {e}"), @@ -304,7 +304,7 @@ fn parse_json_object_snippet( "Gemini 通用配置片段必须是 JSON 对象", "Gemini common config snippet must be a JSON object", ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.not_object", "OpenCode 通用配置片段必须是 JSON 对象", "OpenCode common config snippet must be a JSON object", @@ -339,7 +339,11 @@ pub(super) fn validate_common_config_snippet( } match app_type { - AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { parse_json_object_snippet(app_type, snippet, false)?; } AppType::Codex => { @@ -395,7 +399,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -460,7 +464,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } @@ -509,7 +513,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index d6d8c839..6767ee48 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -26,6 +26,9 @@ pub(super) enum LiveSnapshot { OpenCode { config: Option, }, + Hermes { + config_source: Option, + }, OpenClaw { config_source: Option, }, @@ -88,6 +91,14 @@ impl LiveSnapshot { delete_file(&path)?; } } + LiveSnapshot::Hermes { config_source } => { + let path = crate::hermes_config::get_hermes_config_path(); + if let Some(source) = config_source { + crate::hermes_config::write_hermes_config_source(source)?; + } else if path.exists() { + delete_file(&path)?; + } + } LiveSnapshot::OpenClaw { config_source } => { let path = crate::openclaw_config::get_openclaw_config_path(); if let Some(source) = config_source { @@ -155,6 +166,10 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result { + let config_source = crate::hermes_config::read_hermes_config_source()?; + Ok(LiveSnapshot::Hermes { config_source }) + } AppType::OpenClaw => { let config_source = crate::openclaw_config::read_openclaw_config_source()?; Ok(LiveSnapshot::OpenClaw { config_source }) @@ -162,6 +177,54 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result Result { + let providers = crate::hermes_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0usize; + let existing_ids = state.db.get_provider_ids("hermes")?; + + for (id, settings_config) in providers { + if id.trim().is_empty() { + log::warn!("Skipping Hermes provider with empty id"); + continue; + } + if existing_ids.contains(&id) { + log::debug!("Hermes provider '{id}' already exists in database, skipping"); + continue; + } + if !settings_config.is_object() { + log::warn!("Skipping Hermes provider '{id}': config is not an object"); + continue; + } + + let display_name = settings_config + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&id) + .to_string(); + let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None); + provider.meta = Some(ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + if let Err(err) = state.db.save_provider("hermes", &provider) { + log::warn!("Failed to import Hermes provider '{id}': {err}"); + continue; + } + + imported += 1; + log::info!("Imported Hermes provider '{id}' from live config"); + } + + Ok(imported) +} + pub fn sync_openclaw_providers_from_live(state: &AppState) -> Result { if !crate::openclaw_config::get_openclaw_config_path().exists() { return Ok(0); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 04b9e2a9..1b91e910 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -94,6 +94,7 @@ struct PostCommitAction { refresh_snapshot: bool, common_config_snippet: Option, takeover_active: bool, + activate_provider: bool, } impl ProviderService { @@ -257,6 +258,8 @@ impl ProviderService { let read_presence = || match app_type { AppType::OpenCode => crate::opencode_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::Hermes => crate::hermes_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => Self::valid_openclaw_live_provider_ids() .map(|ids| ids.is_some_and(|ids| ids.contains(provider_id))), _ => Ok(false), @@ -450,6 +453,12 @@ impl ProviderService { action.common_config_snippet.as_deref(), apply_common_config, )?; + if action.activate_provider && matches!(action.app_type, AppType::Hermes) { + crate::hermes_config::set_current_provider( + &action.provider.id, + &action.provider.settings_config, + )?; + } } if action.sync_mcp { // 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用 @@ -701,6 +710,26 @@ impl ProviderService { } state.save()?; } + AppType::Hermes => { + let providers = crate::hermes_config::get_providers()?; + let live_after = providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "hermes.live.missing_provider", + format!("Hermes live 配置中缺少供应商: {provider_id}"), + format!("Hermes live config missing provider: {provider_id}"), + ) + })?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } AppType::OpenClaw => { let providers = crate::openclaw_config::get_providers()?; let live_after = providers.get(provider_id).cloned().ok_or_else(|| { @@ -776,7 +805,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::OpenClaw => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(()), }; match result { @@ -843,6 +872,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet: config.common_config_snippets.get(app_type).cloned(), takeover_active, + activate_provider: false, })) } @@ -891,7 +921,7 @@ impl ProviderService { } AppType::Gemini => live_settings.get("env") != provider_settings.get("env"), AppType::Claude => live_settings != provider_settings, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -1041,6 +1071,7 @@ impl ProviderService { AppType::Codex => Self::extract_codex_common_config(settings_config), AppType::Gemini => Self::extract_gemini_common_config(settings_config), AppType::OpenCode => Self::extract_opencode_common_config(settings_config), + AppType::Hermes => Self::extract_opencode_common_config(settings_config), AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config), } } @@ -1405,6 +1436,10 @@ impl ProviderService { /// 获取当前供应商 ID pub fn current(state: &AppState, app_type: AppType) -> Result { + if matches!(app_type, AppType::Hermes) { + return crate::hermes_config::get_current_provider_id() + .map(|opt| opt.unwrap_or_default()); + } if app_type.is_additive_mode() { return Ok(String::new()); } @@ -1482,6 +1517,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1626,6 +1662,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1708,6 +1745,7 @@ impl ProviderService { }) } AppType::OpenCode => unreachable!("additive mode apps are handled earlier"), + AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), }; @@ -1817,6 +1855,17 @@ impl ProviderService { } crate::opencode_config::read_opencode_config() } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "hermes.config.missing", + "Hermes 配置文件不存在", + "Hermes configuration file not found", + )); + } + crate::hermes_config::read_hermes_config_json() + } AppType::OpenClaw => { let config_path = crate::openclaw_config::get_openclaw_config_path(); if !config_path.exists() { @@ -1889,6 +1938,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -2107,6 +2161,7 @@ impl ProviderService { .get(&app_type_clone) .cloned(), takeover_active: false, + activate_provider: matches!(&app_type_clone, AppType::Hermes), }; return Ok(((), Some(action))); @@ -2130,6 +2185,7 @@ impl ProviderService { effective_current_provider.as_deref(), )?, AppType::OpenCode => unreachable!("additive mode handled above"), + AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), }; @@ -2141,6 +2197,7 @@ impl ProviderService { refresh_snapshot: true, common_config_snippet: config.common_config_snippets.get(&app_type_clone).cloned(), takeover_active: false, + activate_provider: false, }; Ok(((), Some(action))) @@ -2202,6 +2259,17 @@ impl ProviderService { Err(_) => crate::opencode_config::set_provider(&provider.id, config_to_write), } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone()) + .map(|_| ()) + } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); let looks_like_provider = settings_config.get("baseUrl").is_some() @@ -2428,6 +2496,9 @@ impl ProviderService { AppType::OpenCode => Err(AppError::Config( "OpenCode does not support proxy takeover backups".into(), )), + AppType::Hermes => Err(AppError::Config( + "Hermes does not support proxy takeover backups".into(), + )), AppType::OpenClaw => Err(AppError::Config( "OpenClaw does not support proxy takeover backups".into(), )), @@ -2512,6 +2583,15 @@ impl ProviderService { )); } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + } AppType::OpenClaw => { let config = Self::parse_openclaw_provider_settings(&provider.settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; @@ -2650,6 +2730,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -2690,6 +2775,9 @@ impl ProviderService { AppType::OpenCode => { let _ = provider_snapshot; } + AppType::Hermes => { + let _ = provider_snapshot; + } AppType::OpenClaw => { let _ = provider_snapshot; } @@ -2726,6 +2814,10 @@ impl ProviderService { live::import_openclaw_providers_from_live(state) } + pub fn import_hermes_providers_from_live(state: &AppState) -> Result { + live::import_hermes_providers_from_live(state) + } + pub fn import_opencode_providers_from_live(state: &AppState) -> Result { live::import_opencode_providers_from_live(state) } diff --git a/src-tauri/src/services/provider/tests.rs b/src-tauri/src/services/provider/tests.rs index d439cca2..599df332 100644 --- a/src-tauri/src/services/provider/tests.rs +++ b/src-tauri/src/services/provider/tests.rs @@ -3628,6 +3628,38 @@ command = "npx" ); } +#[test] +fn extract_codex_common_config_keeps_profile_settings_provider_owned() { + let config_toml = r#"model_provider = "first" +model = "gpt-5" +profile = "work" +disable_response_storage = true + +[model_providers.first] +base_url = "https://api.example/v1" + +[profiles.work] +model_provider = "first" +model = "gpt-5" +"#; + + let extracted = ProviderService::extract_codex_common_config_from_config_toml(config_toml) + .expect("extract"); + + assert!( + extracted.contains("disable_response_storage = true"), + "regular shared settings should still be extracted" + ); + assert!( + !extracted.contains("profile = \"work\""), + "active profile belongs to the provider snapshot" + ); + assert!( + !extracted.contains("[profiles.work]"), + "profile overrides can carry provider-specific model_provider ids" + ); +} + #[test] #[serial] fn provider_add_tolerates_invalid_codex_common_snippet_during_storage_normalization() { diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index 38008c1e..fa14b3a0 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -404,6 +404,19 @@ impl ProviderService { ) }) .map(|s| s.to_string()), + AppType::Hermes => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }) + .map(|s| s.to_string()), AppType::OpenClaw => provider .settings_config .get("apiKey") @@ -493,6 +506,14 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Hermes => Ok(provider + .settings_config + .get("baseUrl") + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f90df1a1..c09a7f81 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -391,6 +391,7 @@ impl SkillService { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] .into_iter() } @@ -444,6 +445,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::Hermes => { + if let Some(custom) = crate::settings::get_hermes_override_dir() { + return Ok(custom.join("skills")); + } + } AppType::OpenClaw => { if let Some(custom) = crate::settings::get_openclaw_override_dir() { return Ok(custom.join("skills")); @@ -464,6 +470,7 @@ impl SkillService { AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), + AppType::Hermes => home.join(".hermes").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), }) } @@ -946,6 +953,7 @@ impl SkillService { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { if let Err(e) = Self::remove_from_app(&dir, &app) { log::warn!("从 {app:?} 删除 Skill {dir} 失败: {e}"); diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index 8b56de99..717b4355 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -30,6 +30,28 @@ impl StreamCheckService { .and_then(|value| value.as_object()) .and_then(|models| models.keys().next().cloned()) .unwrap_or_else(|| config.codex_model.clone()), + AppType::Hermes => provider + .settings_config + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_object()) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_array()) + .and_then(|models| models.first()) + .and_then(|model| model.get("id").and_then(|value| value.as_str())) + .map(str::to_string) + }) + .unwrap_or_else(|| config.codex_model.clone()), AppType::OpenClaw => provider .settings_config .get("models") @@ -168,6 +190,16 @@ impl StreamCheckService { .unwrap_or_default() .trim_end_matches('/') .to_string()), + AppType::Hermes => Ok(provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim_end_matches('/') + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") @@ -218,6 +250,19 @@ impl StreamCheckService { "API key is missing", ) }), + AppType::Hermes => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(|key| AuthInfo::new(key.to_string(), AuthStrategy::Bearer)) + .ok_or_else(|| { + AppError::localized( + "provider.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }), AppType::OpenClaw => provider .settings_config .get("apiKey") diff --git a/src-tauri/src/services/stream_check/service.rs b/src-tauri/src/services/stream_check/service.rs index 7f9314a1..4bc2d8f4 100644 --- a/src-tauri/src/services/stream_check/service.rs +++ b/src-tauri/src/services/stream_check/service.rs @@ -103,8 +103,8 @@ impl StreamCheckService { provider: &Provider, config: &StreamCheckConfig, ) -> Result { - if matches!(app_type, AppType::OpenClaw) { - return Err(AppError::Message("OpenClaw 暂不支持流式检查".to_string())); + if matches!(app_type, AppType::Hermes | AppType::OpenClaw) { + return Err(AppError::Message(format!("{} 暂不支持流式检查", app_type))); } let start = Instant::now(); @@ -161,6 +161,7 @@ impl StreamCheckService { ) .await } + AppType::Hermes => unreachable!("Hermes should return unsupported earlier"), AppType::OpenClaw => unreachable!("OpenClaw should return unsupported earlier"), }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4eef98db..4df3b84d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -18,6 +18,8 @@ pub struct VisibleApps { pub gemini: bool, #[serde(default = "default_visible_app_opencode")] pub opencode: bool, + #[serde(default = "default_visible_app_hermes")] + pub hermes: bool, #[serde(default = "default_visible_app_openclaw")] pub openclaw: bool, } @@ -38,6 +40,10 @@ fn default_visible_app_opencode() -> bool { true } +fn default_visible_app_hermes() -> bool { + true +} + fn default_visible_app_openclaw() -> bool { true } @@ -48,6 +54,7 @@ pub fn default_visible_apps() -> VisibleApps { codex: true, gemini: false, opencode: true, + hermes: true, openclaw: true, } } @@ -72,6 +79,7 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, } } @@ -82,6 +90,7 @@ impl VisibleApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, } } @@ -103,12 +112,13 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 5] { +fn app_order() -> [AppType; 6] { [ AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] } @@ -313,6 +323,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, @@ -323,6 +335,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, #[serde(default = "default_visible_apps")] pub visible_apps: VisibleApps, @@ -369,11 +383,13 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, + hermes_config_dir: None, openclaw_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, + current_provider_hermes: None, current_provider_openclaw: None, visible_apps: default_visible_apps(), language: None, @@ -424,6 +440,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.hermes_config_dir = self + .hermes_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.openclaw_config_dir = self .openclaw_config_dir .as_ref() @@ -594,6 +617,14 @@ pub fn get_opencode_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_hermes_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .hermes_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + pub fn get_openclaw_override_dir() -> Option { let settings = settings_store().read().ok()?; settings @@ -609,6 +640,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), + AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } @@ -621,6 +653,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Codex => settings.current_provider_codex = id.map(|value| value.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|value| value.to_string()), AppType::OpenCode => settings.current_provider_opencode = id.map(|value| value.to_string()), + AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index db557b02..7ee148a4 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -127,6 +127,14 @@ impl AppState { Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"), } + match crate::services::provider::ProviderService::import_hermes_providers_from_live(self) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} Hermes provider(s) from live config"); + } + Ok(_) => log::debug!("○ No new Hermes providers to import"), + Err(error) => log::warn!("✗ Failed to import Hermes providers: {error}"), + } + match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self) { Ok(count) if count > 0 => { @@ -225,6 +233,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.codex.prompts = prompts.into_iter().collect(), AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(), AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(), + AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), } @@ -276,6 +286,7 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] { let app_key = app.as_str(); diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 7eb92699..a744d11f 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -20,6 +20,8 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::Gemini => crate::gemini_config::get_gemini_dir().exists(), // OpenCode is considered initialized if ~/.config/opencode (or override dir) exists. AppType::OpenCode => crate::opencode_config::get_opencode_dir().exists(), + // Hermes is considered initialized if ~/.hermes (or override dir) exists. + AppType::Hermes => crate::hermes_config::get_hermes_dir().exists(), // OpenClaw is considered initialized if ~/.openclaw (or override dir) exists. AppType::OpenClaw => get_openclaw_dir().exists(), } diff --git a/src-tauri/tests/app_config_load.rs b/src-tauri/tests/app_config_load.rs index 7a33410d..1099b222 100644 --- a/src-tauri/tests/app_config_load.rs +++ b/src-tauri/tests/app_config_load.rs @@ -146,6 +146,19 @@ fn default_config_contains_openclaw_prompt_root_and_manager() { ); } +#[test] +fn default_config_contains_hermes_prompt_root_and_manager() { + let config = MultiAppConfig::default(); + + assert!(config + .get_manager(&cc_switch_lib::AppType::Hermes) + .is_some()); + assert!( + config.prompts.hermes.prompts.is_empty(), + "default Hermes prompt store should exist" + ); +} + #[test] fn update_settings_persists_openclaw_override_dir() { let _guard = lock_test_mutex(); @@ -168,6 +181,28 @@ fn update_settings_persists_openclaw_override_dir() { ); } +#[test] +fn update_settings_persists_hermes_override_dir() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let home = ensure_test_home(); + let _config_dir = ConfigDirEnvGuard::set(None); + + let mut settings = AppSettings::default(); + settings.hermes_config_dir = Some("~/custom-hermes".to_string()); + update_settings(settings).expect("save settings with hermes override"); + + let path = home.join(".cc-switch").join("settings.json"); + let raw = fs::read_to_string(&path).expect("read settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json"); + assert_eq!( + value + .get("hermesConfigDir") + .and_then(|entry| entry.as_str()), + Some("~/custom-hermes") + ); +} + #[test] fn update_settings_uses_cc_switch_config_dir_override_for_settings_path() { let _guard = lock_test_mutex(); diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs index d86943ac..0571116f 100644 --- a/src-tauri/tests/app_type_parse.rs +++ b/src-tauri/tests/app_type_parse.rs @@ -6,6 +6,7 @@ use cc_switch_lib::AppType; fn parse_known_apps_case_insensitive_and_trim() { assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude))); assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex))); + assert!(matches!(AppType::from_str("hermes"), Ok(AppType::Hermes))); assert!(matches!( AppType::from_str("openclaw"), Ok(AppType::OpenClaw) @@ -15,6 +16,10 @@ fn parse_known_apps_case_insensitive_and_trim() { Ok(AppType::Claude) )); assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex))); + assert!(matches!( + AppType::from_str(" HeRmEs\t"), + Ok(AppType::Hermes) + )); assert!(matches!( AppType::from_str("\nOpenClaw\t"), Ok(AppType::OpenClaw) @@ -27,6 +32,12 @@ fn openclaw_is_listed_and_uses_additive_mode() { assert!(AppType::OpenClaw.is_additive_mode()); } +#[test] +fn hermes_is_listed_and_uses_additive_mode() { + assert!(AppType::all().any(|app| app == AppType::Hermes)); + assert!(AppType::Hermes.is_additive_mode()); +} + #[test] fn parse_unknown_app_returns_localized_error_message() { let err = AppType::from_str("unknown").unwrap_err(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 8e0df568..5d5db97a 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -10,6 +10,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -20,6 +21,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -316,3 +318,43 @@ fn settings_current_provider_openclaw_falls_back_to_db_when_cleanup_fails() { Some("db-openclaw") ); } + +#[test] +#[serial] +fn settings_current_provider_hermes_matches_upstream_placeholder_behavior() { + let _home = HomeGuard::new(); + + set_current_provider(&AppType::Hermes, Some("local-hermes")) + .expect("store local hermes provider placeholder"); + assert_eq!( + get_current_provider(&AppType::Hermes).as_deref(), + Some("local-hermes") + ); + + let mut db = Database::default(); + db.insert_provider("hermes", "local-hermes"); + db.set_db_current("hermes", "db-hermes"); + + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("resolve effective hermes provider") + .as_deref(), + Some("local-hermes"), + "existing local placeholder should win while it still exists in the database" + ); + + set_current_provider(&AppType::Hermes, Some("missing-hermes")) + .expect("overwrite local hermes placeholder"); + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("fallback to database current provider") + .as_deref(), + Some("db-hermes"), + "missing local placeholder should be cleared and fall back to database current" + ); + assert_eq!( + get_current_provider(&AppType::Hermes), + None, + "invalid local placeholder should be removed after fallback" + ); +} diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index e7784e84..f5f0f80e 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -13,6 +13,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -23,6 +24,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -292,6 +294,7 @@ fn default_visible_apps_hide_gemini() { AppType::Codex, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] ); assert!(!visible.is_enabled_for(&AppType::Gemini)); @@ -308,6 +311,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { gemini: true, opencode: false, openclaw: true, + hermes: true, }) .expect("persist visible apps"); @@ -324,6 +328,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { "gemini": true, "opencode": false, "openclaw": true, + "hermes": true, }) ); } @@ -341,6 +346,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { "gemini": true, "opencode": true, "openclaw": false, + "hermes": true, } }), ); @@ -356,11 +362,17 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { gemini: true, opencode: true, openclaw: false, + hermes: true, } ); assert_eq!( visible.ordered_enabled(), - vec![AppType::Codex, AppType::Gemini, AppType::OpenCode] + vec![ + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes + ] ); } @@ -387,6 +399,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { gemini: false, opencode: true, openclaw: true, + hermes: true, } ); } @@ -423,6 +436,7 @@ fn set_visible_apps_rejects_zero_selection() { gemini: false, opencode: false, openclaw: false, + hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -444,6 +458,7 @@ fn update_settings_rejects_all_false_visible_apps() { gemini: false, opencode: false, openclaw: false, + hermes: false, }; let err = @@ -491,7 +506,8 @@ fn load_normalizes_all_false_visible_apps_to_defaults() { "codex": false, "gemini": false, "opencode": false, - "openclaw": false + "openclaw": false, + "hermes": false } }), ); @@ -535,6 +551,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { gemini: false, opencode: true, openclaw: true, + hermes: true, }; assert_eq!( @@ -543,10 +560,18 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { ); assert_eq!( next_visible_app(&visible, &AppType::OpenClaw, 1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, 1), Some(AppType::Claude) ); assert_eq!( next_visible_app(&visible, &AppType::Claude, -1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, -1), Some(AppType::OpenClaw) ); assert_eq!(