Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/goose-server/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ serde_path_to_error = "0.1.20"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
url = { workspace = true }
rand = "0.9.2"
regex = "1.10"
hex = "0.4.3"
socket2 = "0.6.1"
fs2 = { workspace = true }
Expand Down
2 changes: 2 additions & 0 deletions crates/goose-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ pub mod error;
pub mod openapi;
pub mod routes;
pub mod state;
pub mod theme_css;
pub mod tunnel;

// Re-export commonly used items
pub use openapi::*;
pub use state::*;
pub use theme_css::generate_mcp_theme_variables;
1 change: 1 addition & 0 deletions crates/goose-server/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod logging;
mod openapi;
mod routes;
mod state;
mod theme_css;
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module declaration mod theme_css; is added but the actual theme_css.rs file is not included in this PR. This will cause a compilation error as the module cannot be found.

Suggested change
mod theme_css;

Copilot uses AI. Check for mistakes.
mod tunnel;

use clap::{Parser, Subcommand};
Expand Down
4 changes: 4 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::set_config_provider,
super::routes::config_management::configure_provider_oauth,
super::routes::config_management::get_pricing,
super::routes::config_management::get_theme_variables,
super::routes::config_management::save_theme,
super::routes::prompts::get_prompts,
super::routes::prompts::get_prompt,
super::routes::prompts::save_prompt,
Expand Down Expand Up @@ -446,6 +448,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::PricingQuery,
super::routes::config_management::PricingResponse,
super::routes::config_management::PricingData,
super::routes::config_management::ThemeVariablesResponse,
super::routes::config_management::SaveThemeRequest,
super::routes::prompts::PromptsListResponse,
super::routes::prompts::PromptContentResponse,
super::routes::prompts::SavePromptRequest,
Expand Down
49 changes: 49 additions & 0 deletions crates/goose-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,53 @@
Ok(Json("OAuth configuration completed".to_string()))
}

#[derive(Serialize, ToSchema)]
pub struct ThemeVariablesResponse {
variables: HashMap<String, String>,
}

#[utoipa::path(
get,
path = "/theme/variables",
responses(
(status = 200, description = "MCP theme variables with light-dark() format", body = ThemeVariablesResponse)
)
)]
pub async fn get_theme_variables() -> Json<ThemeVariablesResponse> {
use crate::theme_css;
let variables = theme_css::generate_mcp_theme_variables();
Json(ThemeVariablesResponse { variables })
}

#[derive(Deserialize, ToSchema)]
Comment on lines +854 to +857
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The theme_css module is referenced but not included in this PR. The function generate_mcp_theme_variables() is called but its implementation is missing from the changeset. This will cause a compilation error.

Copilot uses AI. Check for mistakes.
pub struct SaveThemeRequest {
/// CSS content for theme.css file. If empty, deletes the theme file (reset).
css: String,
}

#[utoipa::path(
post,
path = "/theme/save",
request_body = SaveThemeRequest,
responses(
(status = 200, description = "Theme saved successfully", body = String),
(status = 500, description = "Failed to save theme")

Check warning on line 869 in crates/goose-server/src/routes/config_management.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/routes/config_management.rs

Check warning on line 869 in crates/goose-server/src/routes/config_management.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/routes/config_management.rs
)
)]
pub async fn save_theme(Json(request): Json<SaveThemeRequest>) -> Result<Json<String>, ErrorResponse> {
let theme_path = Paths::in_data_dir("theme.css");

if request.css.trim().is_empty() {
if theme_path.exists() {
std::fs::remove_file(&theme_path)?;
}
Ok(Json("Theme reset successfully".to_string()))
} else {
std::fs::write(&theme_path, request.css)?;
Ok(Json("Theme saved successfully".to_string()))
}
}

pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/config", get(read_all_config))
Expand Down Expand Up @@ -868,6 +915,8 @@
"/config/providers/{name}/oauth",
post(configure_provider_oauth),
)
.route("/theme/variables", get(get_theme_variables))
.route("/theme/save", post(save_theme))
.with_state(state)
}

Expand Down
172 changes: 172 additions & 0 deletions crates/goose-server/src/theme_css.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
use goose::config::paths::Paths;
use regex::Regex;
use std::collections::HashMap;

const MAIN_CSS: &str = include_str!("../../../ui/desktop/src/styles/main.css");

/// Theme CSS Generation
/// ====================
///
/// Both main.css and user's theme.css use MCP-compliant variable names.
/// This module simply:
/// 1. Parses main.css to get default MCP color variables from :root and .dark
/// 2. Parses user's theme.css (if exists) which also uses MCP names
/// 3. Merges them (user overrides defaults)
/// 4. Returns variables in light-dark(light_value, dark_value) format for frontend injection

Check failure on line 15 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Lint Rust Code

empty line after doc comment

fn parse_css_variables(css: &str) -> (HashMap<String, String>, HashMap<String, String>) {
let mut root_vars = HashMap::new();
let mut dark_vars = HashMap::new();

let var_regex = Regex::new(r"--([a-z0-9-]+):\s*([^;]+);").unwrap();

let mut in_root = false;
let mut in_dark = false;
let mut brace_depth = 0;

for line in css.lines() {
let trimmed = line.trim();

if trimmed.starts_with(":root") {
in_root = true;
in_dark = false;
brace_depth = 0;
} else if trimmed.starts_with(".dark") {
in_dark = true;
in_root = false;
brace_depth = 0;
}

brace_depth += trimmed.chars().filter(|&c| c == '{').count() as i32;
brace_depth -= trimmed.chars().filter(|&c| c == '}').count() as i32;

if brace_depth <= 0 {
in_root = false;
in_dark = false;
}

if let Some(caps) = var_regex.captures(trimmed) {
let name = caps.get(1).unwrap().as_str().to_string();
let value = caps.get(2).unwrap().as_str().trim().to_string();

if in_root {
root_vars.insert(name, value);
} else if in_dark {
dark_vars.insert(name, value);
}
}
}

(root_vars, dark_vars)
}

fn resolve_var_reference(value: &str, vars: &HashMap<String, String>) -> String {
let var_ref_regex = Regex::new(r"var\(--([a-z0-9-]+)\)").unwrap();

let mut result = value.to_string();
let mut iterations = 0;
const MAX_ITERATIONS: usize = 10;

while iterations < MAX_ITERATIONS {
if let Some(caps) = var_ref_regex.captures(&result.clone()) {
let var_name = caps.get(1).unwrap().as_str();
if let Some(resolved) = vars.get(var_name) {
let full_match = caps.get(0).unwrap().as_str();
result = result.replace(full_match, resolved);
iterations += 1;
} else {
break;
}
} else {
break;
}
}

result
}

pub fn generate_mcp_theme_variables() -> HashMap<String, String> {
let (main_root, main_dark) = parse_css_variables(MAIN_CSS);
let mut merged_root = main_root.clone();

let mut merged_dark = main_dark.clone();

let theme_path = Paths::in_data_dir("theme.css");
if theme_path.exists() {
if let Ok(theme_css) = std::fs::read_to_string(&theme_path) {
let (theme_root, theme_dark) = parse_css_variables(&theme_css);
merged_root.extend(theme_root);
merged_dark.extend(theme_dark);
}

Check warning on line 100 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs

Check warning on line 100 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs
};


let resolved_root: HashMap<String, String> = merged_root
.iter()
.map(|(k, v)| (k.clone(), resolve_var_reference(v, &merged_root)))
.collect();

let resolved_dark: HashMap<String, String> = merged_dark
.iter()
.map(|(k, v)| (k.clone(), resolve_var_reference(v, &merged_dark)))
.collect();

let mut result = HashMap::new();

for (name, light_value) in &resolved_root {
if name.starts_with("color-") {
let dark_value = resolved_dark.get(name).unwrap_or(light_value);

let formatted = format!("light-dark({}, {})", light_value, dark_value);
result.insert(format!("--{}", name), formatted);
}
}

for (name, dark_value) in &resolved_dark {
if name.starts_with("color-") && !result.contains_key(&format!("--{}", name)) {
let light_value = resolved_root.get(name).unwrap_or(dark_value);
let formatted = format!("light-dark({}, {})", light_value, dark_value);
result.insert(format!("--{}", name), formatted);
}
}

result
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_parse_css_variables() {
let css = r#"
:root {
--color-background-primary: #ffffff;
--color-text-primary: var(--color-neutral-800);
}
.dark {
--color-background-primary: #000000;
}
"#;

Check warning on line 150 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs

Check warning on line 150 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs

let (root, dark) = parse_css_variables(css);
assert_eq!(root.get("color-background-primary"), Some(&"#ffffff".to_string()));
assert_eq!(dark.get("color-background-primary"), Some(&"#000000".to_string()));
}

#[test]
fn test_resolve_var_reference() {

Check warning on line 158 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs

Check warning on line 158 in crates/goose-server/src/theme_css.rs

View workflow job for this annotation

GitHub Actions / Check Rust Code Format

Diff in /home/runner/work/goose/goose/crates/goose-server/src/theme_css.rs
let mut vars = HashMap::new();
vars.insert("color-red".to_string(), "#ff0000".to_string());
vars.insert("color-text-danger".to_string(), "var(--color-red)".to_string());

assert_eq!(
resolve_var_reference("var(--color-red)", &vars),
"#ff0000"
);
assert_eq!(
resolve_var_reference("var(--color-text-danger)", &vars),
"#ff0000"
);
}
}
80 changes: 80 additions & 0 deletions ui/desktop/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3236,6 +3236,59 @@
}
}
},
"/theme/save": {
"post": {
"tags": [
"super::routes::config_management"
],
"operationId": "save_theme",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SaveThemeRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Theme saved successfully",
"content": {
"text/plain": {
"schema": {
"type": "string"
}
}
}
},
"500": {
"description": "Failed to save theme"
}
}
}
},
"/theme/variables": {
"get": {
"tags": [
"super::routes::config_management"
],
"operationId": "get_theme_variables",
"responses": {
"200": {
"description": "MCP theme variables with light-dark() format",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ThemeVariablesResponse"
}
}
}
}
}
}
},
"/tunnel/start": {
"post": {
"tags": [
Expand Down Expand Up @@ -6218,6 +6271,18 @@
}
}
},
"SaveThemeRequest": {
"type": "object",
"required": [
"css"
],
"properties": {
"css": {
"type": "string",
"description": "CSS content for theme.css file. If empty, deletes the theme file (reset)."
}
}
},
"ScanRecipeRequest": {
"type": "object",
"required": [
Expand Down Expand Up @@ -6884,6 +6949,21 @@
}
}
},
"ThemeVariablesResponse": {
"type": "object",
"required": [
"variables"
],
"properties": {
"variables": {
"type": "object",
"description": "MCP-compatible CSS variables with light-dark() format\nThese variables use MCP standard naming (--color-*) and light-dark() format\nfor seamless integration with both the main app and MCP apps.",
"additionalProperties": {
"type": "string"
}
}
}
},
"ThinkingContent": {
"type": "object",
"required": [
Expand Down
Loading
Loading